Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12b1fec0b9 | |||
| f7f9555683 | |||
| 549869b2bb | |||
| 71bef19514 | |||
| 75c7ea67d5 | |||
| 23f4a2c11a | |||
| cb980704bf | |||
| a7bd6ff9d8 | |||
| 4fe1351e1e | |||
| fd5c432735 | |||
| 2c7e648a72 | |||
| 6fc4f1af1d | |||
| dadf89af3d | |||
| 1792ba2a0d | |||
| dbf5604090 | |||
| 769ccec601 | |||
| cfca82ed0c | |||
| 104aa91364 | |||
| c23240d0ea | |||
| 5d4425c060 | |||
| ef80d64f30 | |||
| 65d939dc5b | |||
| 04cafae3d7 | |||
| a74157ff4a | |||
| 4c7e4f9769 | |||
| e7f41d6e27 | |||
| 07adc98e8c | |||
| 5f8c93dee9 | |||
| 127e9b6019 | |||
| ee851f719b | |||
| 06aed39661 | |||
| 00e3116339 | |||
| 27d687becd | |||
| 91057fa3b7 | |||
| 6b6d2969c3 | |||
| 82720ef2fa | |||
| 3a1ea6d97a | |||
| 75a332e7b8 | |||
| 4605d3d0e4 | |||
| beb95c1415 | |||
| e2eafb7407 | |||
| 96e5b9ea14 | |||
| 97d970dd1b | |||
| e3da478116 | |||
| ac76f89dc8 | |||
| c83841cf90 |
95
SKILL.md
95
SKILL.md
@@ -1,68 +1,69 @@
|
||||
---
|
||||
name: 账号管理
|
||||
description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系,供publisher类Skill调用获取账号信息。
|
||||
version: 1.0.4
|
||||
name: 账号与凭据管理
|
||||
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。"
|
||||
version: 2.0.7
|
||||
author: 深圳匠厂科技有限公司
|
||||
metadata:
|
||||
openclaw:
|
||||
slug: account-manager
|
||||
emoji: "👤"
|
||||
emoji: "🔐"
|
||||
category: "通用"
|
||||
allowed-tools:
|
||||
- bash
|
||||
---
|
||||
|
||||
# 账号管理
|
||||
# 账号与凭据管理(account-manager)
|
||||
|
||||
## 使用时机
|
||||
通用账号、凭据与浏览器 Profile 管理器。在本地 SQLite 管理平台注册、账号身份、凭据元数据;可选 Fernet 加密密码入库;提供租约(lease)防并发;供其它技能通过 CLI 调用 `scripts/main.py` 或通过 **同进程** [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md) 完成登录流程。
|
||||
|
||||
当用户发送以下内容的时候触发本Skill:
|
||||
- 说"初始化账号登录"、"登录搜狐账号"、"帮我登录账号"
|
||||
- 说"列出账号"、"查看所有账号"、"有哪些账号"
|
||||
- 说"添加账号"、"删除账号"、"获取账号信息"
|
||||
## 核心原则
|
||||
|
||||
## 执行步骤
|
||||
1. **account-manager 是唯一账号/凭据管理入口** — 业务 skill 不能自己散落存储账号、密码、API Key、Token。
|
||||
2. **SQLite 主要保存元数据** — `secret_ref`、`secret_mask`;非 `local_encrypted` 路径不在库中存明文。
|
||||
3. **浏览器登录态** — 使用独立 `profile_dir` 保存 cookie/session。
|
||||
4. **业务 skill 优先通过 CLI 获取结构化账号 JSON** — 需要时再组合同进程 rpa_helpers(见集成文档)。
|
||||
5. **9 种登录策略统一接口**:`auth_strategy` 标识账号登录方式,业务 skill 可调用 `ensure_logged_in` 自动分派。
|
||||
6. **加密 secret 双钥分离**:master key 文件默认与数据库同目录,也可用环境变量覆盖;**数据库密文 + 合法 master key** 才能解密。
|
||||
|
||||
### 添加账号(平台用中文名;须填合法中国大陆 11 位手机号;同平台下同手机号不可重复;ID 自增,名称如「搜狐1号」)
|
||||
```bash
|
||||
python3 {baseDir}/scripts/main.py add 搜狐号 189xxxxxxxx
|
||||
python3 {baseDir}/scripts/main.py add 知乎 13800138000
|
||||
```
|
||||
支持的平台称呼示例:搜狐号、头条号、知乎、微信公众号、Kimi、DeepSeek、豆包、通义千问、文心一言、腾讯元宝(亦支持英文键如 sohu、toutiao)。
|
||||
## 何时使用本技能
|
||||
|
||||
### 仅打开浏览器核对是否已登录(不写数据库)
|
||||
```bash
|
||||
python3 {baseDir}/scripts/main.py open <id>
|
||||
```
|
||||
1. **登记网页/RPA 账号**:`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir;可选 `--auth-strategy` / `--session-persistent` / `--device-fingerprint`。
|
||||
2. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。
|
||||
3. **查看/列出账号**:`account get/list` — JSON 输出。
|
||||
4. **为 RPA 挑选账号**:`account pick-web` — 按环境/租户/角色筛选,可带 lease。
|
||||
5. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
|
||||
6. **浏览器打开查看**:`account open <id>`(或兼容别名 `open <id>`)— 仅查看,不做站点侧登录判定。
|
||||
7. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。
|
||||
8. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。
|
||||
9. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
|
||||
10. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
|
||||
11. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
|
||||
|
||||
### 登录并自动检测、写入数据库
|
||||
```bash
|
||||
python3 {baseDir}/scripts/main.py login <id>
|
||||
```
|
||||
## CLI 调用
|
||||
|
||||
### 删除账号(同时删库里的记录与 profile 用户数据目录)
|
||||
```bash
|
||||
python3 {baseDir}/scripts/main.py delete id <id>
|
||||
python3 {baseDir}/scripts/main.py delete platform <平台>
|
||||
python3 {baseDir}/scripts/main.py delete platform <平台> <手机号>
|
||||
```
|
||||
语法、默认值、错误约定见 **`references/CLI.md`**。
|
||||
|
||||
### 列出某平台所有账号(platform 可为中文名、all 或 全部)
|
||||
```bash
|
||||
python3 {baseDir}/scripts/main.py list all
|
||||
python3 {baseDir}/scripts/main.py list 搜狐号
|
||||
```
|
||||
## 支持的平台
|
||||
|
||||
### 跨技能:单行 JSON(get / list-json / pick-web / pick-logged-in / set-login-status)
|
||||
```bash
|
||||
python3 {baseDir}/scripts/main.py get <id>
|
||||
python3 {baseDir}/scripts/main.py list-json all --limit 50
|
||||
python3 {baseDir}/scripts/main.py pick-web 搜狐号
|
||||
python3 {baseDir}/scripts/main.py set-login-status <id> 1
|
||||
```
|
||||
约定与字段说明见 `references/INTEGRATION.md`;错误前缀见 `references/ERRORS.md`。
|
||||
见 [`references/PLATFORMS.md`](references/PLATFORMS.md);代码注册表按业务域分为 logistics / llm / content / dev / sim(实际数量与别名以 `scripts/util/platforms.py` 为准)。
|
||||
|
||||
## 扩展文档(便于 AI 精确调用)
|
||||
## 数据与环境
|
||||
|
||||
- **references/**:完整 CLI、平台表、运行时环境、错误码、集成协议 — 见 [references/README.md](references/README.md)。
|
||||
- **assets/**:示例 JSON 与单条账号 Schema — 见 [assets/README.md](assets/README.md)。
|
||||
库路径、profile 布局、环境变量见 [`references/RUNTIME.md`](references/RUNTIME.md);表结构见 [`references/SCHEMA.md`](references/SCHEMA.md)。
|
||||
|
||||
## 跨技能集成
|
||||
|
||||
stdout 协议与同进程 import 见 [`references/INTEGRATION.md`](references/INTEGRATION.md);登录 helper 细节见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
|
||||
|
||||
## Secret 存储
|
||||
|
||||
- `none`:无 secret(浏览器 profile 登录态)
|
||||
- `env`:SQLite 保存环境变量名,运行时从 `os.environ` 读取
|
||||
- `windows_credential`:正式 Windows 部署,真实 secret 存 Windows Credential Manager
|
||||
- `local_encrypted`:Fernet 加密后存 SQLite(`secret_ciphertext`),密钥从 `ACCOUNT_MANAGER_MASTER_KEY` 或 `master.key` 读取(须先 `account init-master-key`)
|
||||
|
||||
详见 [`references/RUNTIME.md`](references/RUNTIME.md)。
|
||||
|
||||
## v2 升级说明
|
||||
|
||||
v1→v2 为**平滑迁移**:启动时 `ensure_schema` 幂等执行 `migrate_v1_to_v2` 与 `migrate_v2_to_v2_1`,自动补列与 `_schema_meta.schema_version`(最终为 `"2.1"`)。旧有三种 `secret_storage`(`none` / `env` / `windows_credential`)仍可用;CLI 旧别名(`get` / `pick-web` / `add` 等)保留。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# assets
|
||||
|
||||
- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。
|
||||
- `schemas/`:`get` / `list-json` / `pick-*` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。
|
||||
- `schemas/`:`get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。
|
||||
|
||||
详细命令与错误码见仓库根目录 `references/`。
|
||||
- 面向用户的介绍见 `references/README.md`。
|
||||
- 面向编排/CLI 的细节见 `references/CLI.md`、`ERRORS.md`、`INTEGRATION.md`(由 `SKILL.md` 渐进引用)。
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# 示例 JSON
|
||||
|
||||
- `get-response.json`:`python main.py get <id>` 成功时 **stdout 单行** 对象的形状参考(示例路径为虚构)。
|
||||
- `list-json-array.json`:`list-json` 成功时 **stdout 单行** 数组的形状参考(本文件为多行排版便于阅读)。
|
||||
- `get-response.json`:`account get <id>` 成功时 stdout 单行对象的形状参考(v2 schema,展示为完整 JSON 对象)。
|
||||
- `list-json-array.json`:`account list` 或 `list-json` 成功时 stdout 单行数组的形状参考(多行排版便于阅读)。
|
||||
|
||||
注意:实际输出为 **`{"success":true,"data":...}`** 包裹的格式(get/list 系列),或直接输出数组(list-json 兼容入口)。
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
{
|
||||
"id": 1,
|
||||
"name": "搜狐1号",
|
||||
"platform": "sohu",
|
||||
"phone": "13800138000",
|
||||
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
|
||||
"url": "https://mp.sohu.com",
|
||||
"login_status": 1,
|
||||
"last_login_at": "2026-04-01T12:00:00",
|
||||
"created_at": "2026-03-01T10:00:00",
|
||||
"updated_at": "2026-04-01T12:00:00"
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 1,
|
||||
"platform_key": "maersk",
|
||||
"account_label": "Maersk simulator booking",
|
||||
"login_id": "demo@maersk.com",
|
||||
"login_id_type": "email",
|
||||
"tenant_id": "tenant-demo",
|
||||
"environment": "simulator",
|
||||
"role": "booking",
|
||||
"provider_code": "maersk",
|
||||
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
|
||||
"url": "http://localhost:5180/industries/logistics/maersk/login",
|
||||
"status": "active",
|
||||
"session_status": "unknown",
|
||||
"created_at": "2026-05-01T09:00:00",
|
||||
"updated_at": "2026-05-01T09:00:00"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
[
|
||||
{
|
||||
"id": 2,
|
||||
"name": "知乎1号",
|
||||
"platform": "zhihu",
|
||||
"phone": "13900001111",
|
||||
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/知乎/13900001111",
|
||||
"url": "https://www.zhihu.com",
|
||||
"login_status": 0,
|
||||
"last_login_at": null,
|
||||
"created_at": "2026-04-02T09:00:00",
|
||||
"updated_at": "2026-04-02T09:00:00"
|
||||
"id": 1,
|
||||
"platform_key": "maersk",
|
||||
"account_label": "Maersk simulator booking",
|
||||
"login_id": "demo@maersk.com",
|
||||
"login_id_type": "email",
|
||||
"tenant_id": "tenant-demo",
|
||||
"environment": "simulator",
|
||||
"role": "booking",
|
||||
"provider_code": "maersk",
|
||||
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
|
||||
"url": "http://localhost:5180/industries/logistics/maersk/login",
|
||||
"status": "active",
|
||||
"session_status": "unknown",
|
||||
"created_at": "2026-05-01T09:00:00",
|
||||
"updated_at": "2026-05-01T09:00:00"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name": "搜狐1号",
|
||||
"platform": "sohu",
|
||||
"phone": "13800138000",
|
||||
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
|
||||
"url": "https://mp.sohu.com",
|
||||
"login_status": 1,
|
||||
"last_login_at": "2026-04-01T12:00:00",
|
||||
"created_at": "2026-03-01T10:00:00",
|
||||
"updated_at": "2026-04-01T12:00:00"
|
||||
"id": 2,
|
||||
"platform_key": "deepseek",
|
||||
"account_label": "DeepSeek production key",
|
||||
"login_id": "",
|
||||
"login_id_type": "unknown",
|
||||
"tenant_id": "",
|
||||
"environment": "production",
|
||||
"role": "api",
|
||||
"provider_code": "deepseek",
|
||||
"profile_dir": "",
|
||||
"url": "https://chat.deepseek.com",
|
||||
"status": "active",
|
||||
"session_status": "unknown",
|
||||
"created_at": "2026-05-01T10:00:00",
|
||||
"updated_at": "2026-05-01T10:00:00"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,23 +2,47 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://openclaw.local/account-manager/account-record.schema.json",
|
||||
"title": "AccountRecord",
|
||||
"description": "account-manager get / list-json / pick-* 返回的单条账号对象(核心字段;extra_json 合并键不枚举)",
|
||||
"description": "account-manager account get / pick-web / list 返回的单条账号对象(v2 schema)",
|
||||
"type": "object",
|
||||
"required": ["id", "name", "platform", "phone", "profile_dir", "url", "login_status", "created_at", "updated_at"],
|
||||
"required": ["id", "platform_key", "account_label", "environment", "role", "status", "session_status"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"platform": { "type": "string", "description": "内部英文键,如 sohu、zhihu" },
|
||||
"phone": { "type": "string", "description": "归一后的数字串,可能为空字符串" },
|
||||
"id": { "type": "integer", "description": "账号主键" },
|
||||
"platform_key": { "type": "string", "description": "平台唯一键,如 maersk、deepseek" },
|
||||
"account_label": { "type": "string", "description": "人可读名称" },
|
||||
"login_id": { "type": "string", "description": "登录标识(email/phone/username/customer_code)" },
|
||||
"login_id_type": { "type": "string", "enum": ["email", "username", "phone", "customer_code", "api_account", "unknown"] },
|
||||
"tenant_id": { "type": "string", "description": "租户/项目标识" },
|
||||
"environment": { "type": "string", "enum": ["simulator", "staging", "production", "test"] },
|
||||
"role": { "type": "string", "description": "booking / tracking / admin / sales / api / default" },
|
||||
"provider_code": { "type": "string", "description": "供应商代码" },
|
||||
"profile_dir": { "type": "string", "description": "Playwright 用户数据目录绝对路径" },
|
||||
"url": { "type": "string", "format": "uri" },
|
||||
"login_status": { "type": "integer", "enum": [0, 1] },
|
||||
"last_login_at": {
|
||||
"type": ["string", "null"],
|
||||
"description": "本地时区 ISO8601 字符串;未登录过为 null"
|
||||
"url": { "type": "string", "description": "账号默认入口 URL" },
|
||||
"status": { "type": "string", "enum": ["active", "disabled", "needs_review"] },
|
||||
"session_status": { "type": "string", "enum": ["unknown", "likely_valid", "needs_login", "expired"] },
|
||||
"last_used_at": { "type": ["string", "null"], "description": "最近使用时间 ISO8601" },
|
||||
"last_login_check_at": { "type": ["string", "null"] },
|
||||
"last_error_code": { "type": "string" },
|
||||
"last_error_message": { "type": "string" },
|
||||
"created_at": { "type": ["string", "null"], "description": "创建时间 ISO8601" },
|
||||
"updated_at": { "type": ["string", "null"], "description": "更新时间 ISO8601" },
|
||||
"credentials": {
|
||||
"type": "array",
|
||||
"description": "关联凭据(仅 account get --with-credentials 时返回)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"credential_label": { "type": "string" },
|
||||
"credential_type": { "type": "string" },
|
||||
"secret_storage": { "type": "string" },
|
||||
"secret_ref": { "type": "string" },
|
||||
"secret_mask": { "type": "string" },
|
||||
"status": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"created_at": { "type": ["string", "null"] },
|
||||
"updated_at": { "type": ["string", "null"] }
|
||||
"lease_token": { "type": "string", "description": "仅 pick-web --lease 时返回" },
|
||||
"lease_expires_at": { "type": "string", "description": "仅 pick-web --lease 时返回" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
|
||||
@@ -1,116 +1,208 @@
|
||||
# account-manager CLI 参考
|
||||
# account-manager CLI 速查
|
||||
|
||||
入口:`{skillRoot}/scripts/main.py`(宿主可将 `{baseDir}` 或技能根目录代入)。下文用 `python main.py` 表示在 `scripts/` 目录执行,或写成 `python {skillRoot}/scripts/main.py`。
|
||||
将 `{baseDir}` 替换为技能根目录。所有命令通过 `python {baseDir}/scripts/main.py` 调用(工作目录可为技能根或 `scripts/`,与命令中的路径一致即可)。下文示例简写为 `python main.py`,表示在 `{baseDir}/scripts/` 下执行。
|
||||
|
||||
> **说明**:内置 `_USAGE`(无参数启动 CLI)已列出加密三类子命令;更完整的 JSON 约定仍以本文与源码为准。
|
||||
|
||||
|
||||
## stdout / JSON 约定(集成必读)
|
||||
|
||||
- 若干命令输出 **单层 JSON 对象**(例如 `account pick-web`、`account get`、`account get-credential` 成功时),**没有**统一的 `{"success":true}` 外层包裹。
|
||||
- `account add-web`、`account add-secret`(等多数字命令)成功时为 **`{"success": true, "data": {...}}`**。
|
||||
- `credential pick` / `credential resolve` 成功时为 **`{"success": true, ...字段}`**。
|
||||
- `platform list` / `platform get` 成功时为 **`{"success": true, "data": ...}`**。
|
||||
- 失败行多为 **`{"success": false, "error": {"code": "...", "message": "..."}}`**(个别老旧路径仍可能打印 `ERROR:` 前缀文本,首行解析逻辑见 [INTEGRATION.md](INTEGRATION.md))。
|
||||
|
||||
---
|
||||
|
||||
## 子命令速查(按业务流程)
|
||||
|
||||
### A. 平台管理
|
||||
|
||||
| CLI | 说明 |
|
||||
|---|---|
|
||||
| `platform list [--domain logistics\|llm\|content]` | 列出平台注册(`--domain` 仅接受这三种;`dev`/`sim` 等平台请省略 `--domain` 查全量或在 JSON 里筛选) |
|
||||
| `platform get <platform>` | 获取数据库中的平台详情 |
|
||||
|
||||
### B. 加密初始化(v2)
|
||||
|
||||
| CLI | 说明 |
|
||||
|---|---|
|
||||
| `account init-master-key` | 生成 master.key(写入 `{数据根}/{用户}/account-manager/master.key`) |
|
||||
| `account init-master-key --print` | 生成密钥并 **单行 JSON** 打印完整 key(备份用,不落盘) |
|
||||
| `account init-master-key --from-env` | 从 `ACCOUNT_MANAGER_MASTER_KEY` 读取合法 Fernet key 并写入文件 |
|
||||
| `account init-master-key --rotate` | 覆盖现有 master.key(stderr 会先打印警告;应先有 export 备份) |
|
||||
|
||||
### C. 账号管理(v2 扩展)
|
||||
|
||||
| CLI | 说明 |
|
||||
|---|---|
|
||||
| `account add-web --platform <p> [--login-id <id>] [--login-id-type <type>] [...]` | 创建网页/RPA 账号;可选 `--auth-strategy` / `--session-persistent 0\|1` / `--device-fingerprint <fp>` |
|
||||
| `account add-secret --platform <p> --credential-type <t> --secret-storage env\|windows_credential\|none\|local_encrypted` | 创建凭据;`local_encrypted` **必须** `--secret-stdin` |
|
||||
| `account get <id> [--with-credentials]` | 输出账号 JSON(若带 `--with-credentials` 则附加关联凭据摘要) |
|
||||
| `account get-credential <account_id> [--reveal --lease-token <token>]` | v2:**按账号 id** 取活跃凭据;`--reveal` 必须配合合法 lease |
|
||||
| `account list [--platform <p>] [--environment <env>] [--tenant-id <t>] [--role <role>] [--limit N]` | 输出账号 JSON **数组** |
|
||||
| `account pick-web --platform <p> [--environment <env>] [--tenant-id <t>] [--role <role>] [--lease] [--ttl-sec 900] [--holder <h>] [--purpose <purpose>]` | 挑选网页自动化候选;返回 JSON 含 `auth_strategy` / `session_persistent` / `device_fingerprint` |
|
||||
| `account mark-session <id> --session-status <status>` | 标记会话状态 |
|
||||
| `account mark-error <id> --code <code> --message <msg>` | 标记错误 |
|
||||
| `account mark-used <id>` | 标记已使用 |
|
||||
| `account open <id>` | 打开持久化浏览器 profile(仅查看) |
|
||||
|
||||
### D. 加密备份与迁移(v2)
|
||||
|
||||
| CLI | 说明 |
|
||||
|---|---|
|
||||
| `account export-credentials --plaintext` | 将所有凭据行导出为 JSON **数组** 打到 stdout(stderr 有人类可读警告);**必须**显式 `--plaintext` |
|
||||
| `account import-credentials [--replace-all]` | 从 stdin 读 JSON 数组,仅对带 `plaintext` 的行调用加密插入;结果摘要打到 **stderr** |
|
||||
|
||||
### E. 凭据 / 租约 / 浏览器(v1 兼容入口保留)
|
||||
|
||||
以下命令仍推荐使用,行为与 v1 时期一致,适合「只拿 API Key」或脚本兼容:
|
||||
|
||||
| CLI | 说明 |
|
||||
|---|---|
|
||||
| `credential pick --platform <p> [--type <type>] [--environment <env>] [--role <role>] [--reveal]` | v1 兼容:挑选凭据 |
|
||||
| `credential resolve --id <credential_id> [--reveal]` | v1 兼容:按凭据 id 解析 |
|
||||
| `lease release <lease_token>` | 释放租约 |
|
||||
| `lease list [--active]` | 列出租约 |
|
||||
| `lease cleanup` | 清理过期租约 |
|
||||
| `account open <id>` / `open <id>` | 浏览器查看 |
|
||||
|
||||
### 兼容入口(旧版别名)
|
||||
|
||||
| CLI | 等价 / 说明 |
|
||||
|---|---|
|
||||
| `get <id>` | `account get <id>` |
|
||||
| `list-json <platform\|all> [--limit N]` | JSON 数组输出 |
|
||||
| `pick-web <platform>` | `account pick-web --platform <platform>`(无 lease 参数) |
|
||||
| `list [platform\|all]` | 人类可读列表 |
|
||||
| `add <platform> <phone>` | 映射到 `account add-web`(`login_id_type=phone`),不传 v2 扩展参数 |
|
||||
| `delete id\|platform <...>` | 删除账号 |
|
||||
|
||||
---
|
||||
|
||||
## account add-web(扩参示例)
|
||||
|
||||
```bash
|
||||
# 扫码登录的抖音账号
|
||||
python main.py account add-web \
|
||||
--platform douyin \
|
||||
--label "douyin-creator-1" \
|
||||
--auth-strategy qr_code_manual \
|
||||
--session-persistent 1
|
||||
```
|
||||
|
||||
- `--auth-strategy`:`password_auto` / `password_with_captcha` / `password_plus_2fa` / `qr_code_manual` / `per_session_manual` / `device_bound` / `client_certificate` / `api_token` / `sso_redirect`;省略时按平台默认(`seed_platforms` + `platforms.default_auth_strategy`,回填规则见 `db/platform_defaults.py`);非法值返回 **`ERR_AUTH_STRATEGY_NOT_SUPPORTED`**。
|
||||
- `--session-persistent`:`0` 或 `1`;省略时由代码推导:`qr_code_manual`、`password_plus_2fa`、`device_bound`、`sso_redirect` → `1`;`per_session_manual`、`client_certificate` → `0`;其余默认 `1`。
|
||||
- `--device-fingerprint`:**仅当** `--auth-strategy=device_bound` 时允许设置;否则 **`ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE`**。
|
||||
- 成功 stdout:`{"success": true, "data": { ... }}`,`data` 内含 `id`、`platform_key`、`profile_dir`、`auth_strategy`、`session_persistent`、`device_fingerprint` 等。
|
||||
|
||||
---
|
||||
|
||||
## account add-secret(`local_encrypted` 示例)
|
||||
|
||||
```bash
|
||||
echo "my-icbc-password" | python main.py account add-secret \
|
||||
--platform icbc_sim \
|
||||
--credential-type password \
|
||||
--secret-storage local_encrypted \
|
||||
--secret-stdin \
|
||||
--label "工行仿真主账号"
|
||||
```
|
||||
|
||||
- `local_encrypted` **必须** `--secret-stdin`(否则 **`ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN`**)。
|
||||
- 允许的 `credential_type`:`password`、`api_key`、`api_token`、`bearer_token`、`refresh_token`、`oauth_client`(`browser_profile` / `note` 会被 **`ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED`** 拒绝)。
|
||||
- stdin 为空:**`ERR_LOCAL_ENCRYPTED_EMPTY_STDIN`**。
|
||||
- master key 未就绪:**`ERR_MASTER_KEY_MISSING`**。
|
||||
- 成功:`{"success": true, "data": {"credential_id", "account_id", "platform_key", "credential_type", "secret_storage", "secret_mask"}}`。
|
||||
|
||||
---
|
||||
|
||||
## account get-credential(示例)
|
||||
|
||||
```bash
|
||||
# 不 reveal:仅返回 metadata + mask
|
||||
python main.py account get-credential 42
|
||||
|
||||
# reveal:必须带合法 lease-token(通常来自 pick-web --lease)
|
||||
LEASE=$(python main.py account pick-web --platform icbc_sim --lease --holder my-rpa | jq -r .lease_token)
|
||||
python main.py account get-credential 42 --reveal --lease-token "$LEASE"
|
||||
```
|
||||
|
||||
- 无 `--reveal`:单行 JSON,`success`/`account_id`/`credential_id`/`secret_storage`/`secret_mask` 等。
|
||||
- `--reveal`:`secret_storage=local_encrypted` 等在 lease 校验通过后追加 **`plaintext`**;若底层存储为 `none`,返回 **`plaintext: null`** 及说明字段(并非抛错)。
|
||||
- 常见错误:`ERR_LEASE_INVALID`、`ERR_CREDENTIAL_MISSING`、`ERR_MASTER_KEY_MISSING`、`ERR_CREDENTIAL_DECRYPT_FAILED`(见 [ERRORS.md](ERRORS.md))。
|
||||
|
||||
---
|
||||
|
||||
## account export-credentials / import-credentials(示例)
|
||||
|
||||
```bash
|
||||
# 备份(stdout 为 JSON 数组,含解密后的 plaintext;务必妥善销毁)
|
||||
python main.py account export-credentials --plaintext > backup-2026-05-11.json
|
||||
|
||||
# 在新机器恢复(需先 init-master-key,使 master key 可用)
|
||||
python main.py account import-credentials --replace-all < backup-2026-05-11.json
|
||||
```
|
||||
|
||||
- 未带 `--plaintext`:**`ERR_EXPORT_REQUIRES_PLAINTEXT_FLAG`**(单行 JSON)。
|
||||
- `import-credentials` 成功时 **stdout 通常为空**,进度与计数在 **stderr**。
|
||||
|
||||
---
|
||||
|
||||
## account init-master-key(示例)
|
||||
|
||||
```bash
|
||||
# 首次初始化(写入 master.key)
|
||||
python main.py account init-master-key
|
||||
|
||||
# 打印 key 备份到密码管理器(不落盘)
|
||||
python main.py account init-master-key --print
|
||||
|
||||
# 从环境变量恢复写入文件
|
||||
ACCOUNT_MANAGER_MASTER_KEY="<base64-44-chars>" python main.py account init-master-key --from-env
|
||||
```
|
||||
|
||||
- 文件已存在且未 `--rotate`:**`ERR_MASTER_KEY_EXISTS`**(JSON)。
|
||||
- `--from-env` 字符串非法:**`ERR_MASTER_KEY_INVALID`**。
|
||||
|
||||
---
|
||||
|
||||
## account pick-web(字段)
|
||||
|
||||
成功时为 **单行 JSON 对象**(非 `_ok_json` 包裹)。核心字段:
|
||||
|
||||
- `id`, `platform_key`, `account_label`, `login_id`, `profile_dir`, `url`, `tenant_id`, `environment`, `role`, `provider_code`, `session_status`
|
||||
- **v2**:`auth_strategy`, `session_persistent`, `device_fingerprint`
|
||||
- 带 `--lease`:`lease_token`, `lease_expires_at`(ISO8601 **本地时间**字符串,由内部 Unix 秒字段转换而来)
|
||||
|
||||
---
|
||||
|
||||
## 9 种 `auth_strategy` 简表(参考)
|
||||
|
||||
| 策略键 | 适用场景 | 是否要 plaintext(典型) | 是否要人介入 |
|
||||
|---|---|---|---|
|
||||
| `password_auto` | 标准账密表单 | 是 | 否 |
|
||||
| `password_with_captcha` | 带图形验证码 | 是 | 是(填码阶段) |
|
||||
| `password_plus_2fa` | 短信/邮件/OTP 2FA | 是 | 是(OTP 阶段) |
|
||||
| `qr_code_manual` | 扫码登录 | 否 | 是(扫码阶段) |
|
||||
| `per_session_manual` | 银行/政务等每次手动 | 否 | 是 |
|
||||
| `device_bound` | 绑机器 / U-Key 等 | 可选(有密码则自动填) | 视情况 |
|
||||
| `client_certificate` | 客户端证书 + PIN | — | 不可自动化(rpa_helpers 抛错) |
|
||||
| `api_token` | 无浏览器登录 | 否 | 否 |
|
||||
| `sso_redirect` | OAuth / SSO 跳转 | 否 | 是(SSO 页阶段) |
|
||||
|
||||
业务 skill 同进程调用详见 [RPA_HELPERS.md](RPA_HELPERS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 通用说明
|
||||
|
||||
- **平台参数**:可为 `scripts/util/platforms.py` 中配置的**中文展示名、别名或英文键**(如 `sohu`、`搜狐号`)。详见 [PLATFORMS.md](PLATFORMS.md)。
|
||||
- **数据与路径**:库文件、profile 目录由环境变量决定,网关与本地终端不一致时会「看不到同一份数据」。详见 [RUNTIME.md](RUNTIME.md)。
|
||||
- **机器可读子命令**:`get`、`list-json`、`pick-logged-in`、`pick-web`、`set-login-status` 的成功/失败约定见 [INTEGRATION.md](INTEGRATION.md)。
|
||||
- **错误码前缀**:见 [ERRORS.md](ERRORS.md)。
|
||||
- **平台参数**:可为 `scripts/util/platforms.py` 中的英文键、`display_name`、任一 alias(不区分大小写)。详见 [PLATFORMS.md](PLATFORMS.md)。
|
||||
- **数据与路径**:库文件、profile、`master.key` 布局见 [RUNTIME.md](RUNTIME.md)。
|
||||
- **机器可读集成**:见 [INTEGRATION.md](INTEGRATION.md)。
|
||||
- **错误码**:见 [ERRORS.md](ERRORS.md)。
|
||||
|
||||
> 说明:当前版本 **未实现** `health` / `version` 子命令;与工作区其它技能的统一约定可对齐后另补。
|
||||
## 内置 `_USAGE`(阶段 F)
|
||||
|
||||
---
|
||||
|
||||
## 子命令一览
|
||||
|
||||
### `add` — 添加账号
|
||||
|
||||
```bash
|
||||
python main.py add <平台> <手机号>
|
||||
```
|
||||
|
||||
- 手机号为**中国大陆 11 位**(`1` 开头,第二位 `3–9`),可含空格/分隔符,入库前会归一成数字。
|
||||
- 同一 `platform` 下同一手机号不可重复。
|
||||
|
||||
### `list` — 列出账号(人类可读多行块)
|
||||
|
||||
```bash
|
||||
python main.py list [平台|all|全部]
|
||||
```
|
||||
|
||||
- 省略第二参数时等价于 `all`。
|
||||
- 默认最多 **10** 条(按创建时间倒序);内部实现见 `service/account_service.cmd_list`。
|
||||
|
||||
### 简写:`python main.py <平台>` / `python main.py <平台> list`
|
||||
|
||||
- 若第一个参数能解析为平台,则等价于 `list <平台>`。
|
||||
|
||||
### `get` — 按 id 输出**单行 JSON**
|
||||
|
||||
```bash
|
||||
python main.py get <id>
|
||||
```
|
||||
|
||||
- 成功:`stdout` **仅一行** JSON 对象(UTF-8)。
|
||||
- 失败:首行 `ERROR:ACCOUNT_NOT_FOUND`,路径提示在 stderr。
|
||||
|
||||
### `list-json` — 列表的**单行 JSON 数组**(跨技能)
|
||||
|
||||
```bash
|
||||
python main.py list-json <平台|all|全部> [--limit N]
|
||||
```
|
||||
|
||||
- 成功:`stdout` **仅一行** JSON 数组;元素结构与 `get` 单条一致。
|
||||
- 默认 `limit=200`,实际 clamp 为 `1..500`。
|
||||
- 排序:按 `updated_at` 倒序。
|
||||
|
||||
### `pick-logged-in` — 该平台**已登录**的一条账号(单行 JSON)
|
||||
|
||||
```bash
|
||||
python main.py pick-logged-in <平台>
|
||||
```
|
||||
|
||||
- 筛选:`login_status = 1`,按 `last_login_at` 优先。
|
||||
- 失败:首行以 `ERROR:` 开头(**勿当 JSON 解析**)。
|
||||
|
||||
### `pick-web` — 网页自动化候选账号(单行 JSON)
|
||||
|
||||
```bash
|
||||
python main.py pick-web <平台>
|
||||
```
|
||||
|
||||
- 优先同 `pick-logged-in`;若无已登录,则取该平台 `updated_at` 最新一条。
|
||||
- 失败:首行 `ERROR:`。
|
||||
|
||||
### `set-login-status` — 回写登录态(跨技能)
|
||||
|
||||
```bash
|
||||
python main.py set-login-status <id> <0|1>
|
||||
```
|
||||
|
||||
- 成功:`stdout` 首行 `OK:SET_LOGIN_STATUS` 或 `OK:CLEARED_LOGIN_STATUS`,退出码 `0`。
|
||||
- 失败:`stdout` 首行 `ERROR:...`,退出码 `1`。
|
||||
|
||||
### `open` — 仅打开浏览器(**不写库**)
|
||||
|
||||
```bash
|
||||
python main.py open <id>
|
||||
```
|
||||
|
||||
- 需本机 Chrome/Edge + Playwright;详见运行环境说明。
|
||||
|
||||
### `login` — 打开浏览器并**自动检测登录**,写回 `login_status`
|
||||
|
||||
```bash
|
||||
python main.py login <id>
|
||||
```
|
||||
|
||||
- 依赖 Playwright;超时与轮询间隔等见 [RUNTIME.md](RUNTIME.md)。
|
||||
|
||||
### `delete` — 删除库记录并尽量删除 profile 目录
|
||||
|
||||
```bash
|
||||
python main.py delete id <id>
|
||||
python main.py delete platform <平台>
|
||||
python main.py delete platform <平台> <手机号>
|
||||
```
|
||||
|
||||
- 第二段必须为 `id` 或 `platform`(小写不敏感)。
|
||||
|
||||
---
|
||||
|
||||
## 无参数 / 未知子命令
|
||||
|
||||
- 无参数或无法解析:打印用法概览;未知子命令时首行 `ERROR:CLI_UNKNOWN_OR_BAD_ARGS`,退出码 `1`。
|
||||
无参数运行 CLI(例如 `python scripts/main.py` / `python -m cli.app`,cwd=`scripts/`)打印的内置 `_USAGE` 已与 `main()` 路由对齐,包含 **`account init-master-key` / `account export-credentials` / `account import-credentials`**。面向外部仓库的契约摘要见 [V1_COMPATIBILITY.md](V1_COMPATIBILITY.md)。
|
||||
|
||||
@@ -1,56 +1,150 @@
|
||||
# 终端前缀约定(account-manager)
|
||||
# 终端错误码(account-manager)
|
||||
|
||||
约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:` 或 `OK:` 开头;多行说明可忽略或仅作展示。
|
||||
约定:**集成解析必须读取 stdout**;多数失败后仍为单行 JSON,`success=false`。老旧别名路径可能出现 **`ERROR:` 文本前缀**——仍以「是否能解析 JSON」为首要判定。
|
||||
|
||||
## `OK:`(成功标记)
|
||||
## 机器 JSON 失败格式(常见)
|
||||
|
||||
| 首行 | 场景 |
|
||||
|------|------|
|
||||
| `OK:SET_LOGIN_STATUS` | `set-login-status <id> 1` 成功 |
|
||||
| `OK:CLEARED_LOGIN_STATUS` | `set-login-status <id> 0` 成功 |
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "……",
|
||||
"message": "……"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `ERROR:` — CLI 参数 / 路由
|
||||
部分 CLI(例如遗留 `ERROR:INVALID_PLATFORM`)仍可能在成功行前额外打印 hint——请以 [`INTEGRATION.md`](INTEGRATION.md) 的建议解析顺序为准。
|
||||
|
||||
---
|
||||
|
||||
## ERROR:CLI — CLI 参数 / 路由
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|------|------|
|
||||
|---|---|
|
||||
| `ERROR:CLI_ADD_MISSING_ARGS` | `add` 参数不足 |
|
||||
| `ERROR:CLI_GET_MISSING_ID` | `get` 缺少 id |
|
||||
| `ERROR:CLI_OPEN_MISSING_ID` | `open` 缺少 id |
|
||||
| `ERROR:CLI_LOGIN_MISSING_ID` | `login` 缺少 id |
|
||||
| `ERROR:CLI_DELETE_MISSING_ARGS` | `delete` 参数不足 |
|
||||
| `ERROR:CLI_DELETE_BAD_MODE` | `delete` 第二段不是 `id`/`platform` |
|
||||
| `ERROR:CLI_SET_LOGIN_STATUS_MISSING_ARGS` | `set-login-status` 参数不足 |
|
||||
| `ERROR:CLI_LIST_JSON_MISSING_PLATFORM` | `list-json` 缺少平台参数 |
|
||||
| `ERROR:CLI_PICK_LOGGED_IN_MISSING_ARGS` | `pick-logged-in` 缺少平台 |
|
||||
| `ERROR:CLI_PICK_WEB_MISSING_ARGS` | `pick-web` 缺少平台 |
|
||||
| `ERROR:CLI_BAD_ARGS` | 子命令参数缺失或非法 |
|
||||
| `ERROR:CLI_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 |
|
||||
|
||||
## `ERROR:` — 业务与数据
|
||||
---
|
||||
|
||||
## ERROR: — 数据库 / 通用
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|------|------|
|
||||
| `ERROR:INVALID_PLATFORM_LIST` | `list` 的平台名无法识别 |
|
||||
| `ERROR:INVALID_PLATFORM_LIST_JSON` | `list-json` 的平台名无法识别 |
|
||||
| `ERROR:INVALID_PLATFORM` | 其它子命令中平台无法识别 |
|
||||
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果(或 `delete platform` 下无记录) |
|
||||
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id/记录不存在 |
|
||||
| `ERROR:PHONE_REQUIRED` | 缺少手机号 |
|
||||
| `ERROR:PHONE_INVALID` | 手机号格式不符合大陆 11 位规则 |
|
||||
| `ERROR:DUPLICATE_PHONE_PLATFORM` | 同平台下山手机号已存在 |
|
||||
| `ERROR:DELETE_INVALID_ID` | 删除 id 非法 |
|
||||
| `ERROR:NO_LOGGED_IN_ACCOUNT` | `pick-logged-in` 无 `login_status=1` 的账号 |
|
||||
| `ERROR:NO_ACCOUNT` | `pick-web` 该平台无任何记录 |
|
||||
| `ERROR:INVALID_ACCOUNT_ID` | `set-login-status` 的 id 非数字 |
|
||||
| `ERROR:INVALID_STATUS` | `set-login-status` 状态不是 0/1 |
|
||||
|---|---|
|
||||
| `ERROR:DB_ERROR` | SQLite 写入失败等 |
|
||||
|
||||
## `ERROR:` — 浏览器 / 环境
|
||||
---
|
||||
|
||||
## ERROR: — 平台与账号
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|------|------|
|
||||
| `ERROR:需要 playwright:pip install playwright && playwright install chromium` | 未安装 Playwright 或浏览器未安装 |
|
||||
|---|---|
|
||||
| `ERROR:INVALID_PLATFORM` | 平台名无法识别 |
|
||||
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果 |
|
||||
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id 不存在 |
|
||||
| `ERROR:ACCOUNT_DISABLED` | 账号状态为 disabled |
|
||||
| `ERROR:NO_ACCOUNT` | `pick-web` 无符合条件的可用账号(含均被 active 未过期 lease 占用) |
|
||||
|
||||
---
|
||||
|
||||
## ERROR: — 租约
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|---|---|
|
||||
| `ERROR:LEASE_CONFLICT` | 同账号已有 active 未过期 lease |
|
||||
| `ERROR:LEASE_NOT_FOUND` | 未找到活跃租约 |
|
||||
| `ERROR:LEASE_EXPIRED` | 租约已过期 |
|
||||
|
||||
---
|
||||
|
||||
## ERROR: — 凭据与 Secret
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|---|---|
|
||||
| `ERROR:CREDENTIAL_NOT_FOUND` | 凭据不存在 |
|
||||
| `ERROR:CREDENTIAL_DISABLED` | 凭据不可用 |
|
||||
| `ERROR:SECRET_STORAGE_UNSUPPORTED` | 不支持的 secret 存储方式 |
|
||||
| `ERROR:SECRET_REF_MISSING` | env 存储缺少环境变量名 |
|
||||
| `ERROR:SECRET_READ_FAILED` | 读取 secret 失败 |
|
||||
| `ERROR:SECRET_WRITE_FAILED` | 写入 secret 失败 |
|
||||
| `ERROR:SECRET_ENV_MISSING` | 环境变量未设置 |
|
||||
| `ERROR:WINDOWS_CREDENTIAL_UNAVAILABLE` | 非 Windows 平台不支持 |
|
||||
|
||||
---
|
||||
|
||||
## ERROR: — 浏览器 / 环境
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|---|---|
|
||||
| `ERROR:需要 playwright Python 包` | 未安装 `playwright` 包或本机无 Chrome/Edge |
|
||||
| `ERROR:PROFILE_DIR_MISSING` | 账号记录中 `profile_dir` 为空 |
|
||||
|
||||
## 非前缀输出
|
||||
---
|
||||
|
||||
- **成功添加账号**、**删除成功**等可能包含 `✅` 等人类可读行,**不宜**作为稳定协议解析。
|
||||
- **`get` / `list-json` / `pick-*` 成功**:`stdout` 应为**单行 JSON**(失败时首行 `ERROR:`,勿当 JSON)。
|
||||
## v2 新增错误码(机器可读 JSON,`code` 字段通常 **无** `ERROR:` 前缀)
|
||||
|
||||
### 加密 / Master Key
|
||||
|
||||
| 错误码 | 触发场景 |
|
||||
|---|---|
|
||||
| `ERR_MASTER_KEY_MISSING` | 环境变量与 `master.key` 均未解析到合法密钥 |
|
||||
| `ERR_MASTER_KEY_EXISTS` | `init-master-key` 时文件已存在且未 `--rotate` |
|
||||
| `ERR_MASTER_KEY_INVALID` | `--from-env` / 环境变量提供字符串无法归一成 Fernet key |
|
||||
| `ERR_CREDENTIAL_DECRYPT_FAILED` | Fernet 解密失败(`CredentialDecryptError`,典型为密文损坏或 key 不匹配) |
|
||||
| `ERR_CREDENTIAL_NOT_ENCRYPTED` | `db.credentials_repo.read_credential_plaintext` 对 `secret_storage='none'` 抛出 **`CredentialNotEncryptedError`**;CLI `get-credential --reveal` **会捕获并返回 `plaintext: null`**(非致命 JSON 错误) |
|
||||
|
||||
### 凭据导入导出
|
||||
|
||||
| 错误码 | 触发场景 |
|
||||
|---|---|
|
||||
| `ERR_EXPORT_REQUIRES_PLAINTEXT_FLAG` | `export-credentials` 未带 `--plaintext` |
|
||||
|
||||
### `add-web` 扩参
|
||||
|
||||
| 错误码 | 触发场景 |
|
||||
|---|---|
|
||||
| `ERR_AUTH_STRATEGY_NOT_SUPPORTED` | `--auth-strategy` 不在允许集合 / 账号缺少策略(同进程 dispatcher 亦使用) |
|
||||
| `ERR_INVALID_SESSION_PERSISTENT` | `--session-persistent` 不是 `0` 或 `1` |
|
||||
| `ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE` | 非 `device_bound` 策略却传入 `--device-fingerprint` |
|
||||
|
||||
### `add-secret` + `local_encrypted`
|
||||
|
||||
| 错误码 | 触发场景 |
|
||||
|---|---|
|
||||
| `ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN` | `local_encrypted` 未配合 `--secret-stdin` |
|
||||
| `ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED` | `credential_type` 不允许加密(如 `browser_profile` / `note`) |
|
||||
| `ERR_LOCAL_ENCRYPTED_EMPTY_STDIN` | stdin 无内容 |
|
||||
|
||||
### `get-credential` / 访问控制
|
||||
|
||||
| 错误码 | 触发场景 |
|
||||
|---|---|
|
||||
| `ERR_LEASE_INVALID` | `--reveal` 时 lease 缺失、过期或不匹配账号 |
|
||||
| `ERR_CREDENTIAL_MISSING` | 账号下无 `status=active` 的凭据 |
|
||||
|
||||
---
|
||||
|
||||
## rpa_helpers(同进程异常,非 CLI)
|
||||
|
||||
详见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。`AuthStrategyError.code` 取值:
|
||||
|
||||
| 错误码 | 触发场景 |
|
||||
|---|---|
|
||||
| `ERR_AUTH_STRATEGY_NOT_SUPPORTED` | `auth_strategy` 为空或 dispatcher 不识别的字符串 |
|
||||
| `ERR_CREDENTIAL_MISSING` | 策略需要 `credentials["plaintext"]` 但未提供 |
|
||||
| `ERR_AUTH_STRATEGY_NOT_AUTOMATABLE` | `client_certificate` |
|
||||
| `ERR_DEVICE_FINGERPRINT_MISMATCH` | `device_bound` 机器指纹与账号不一致 |
|
||||
|
||||
---
|
||||
|
||||
## 非前缀输出提示
|
||||
|
||||
- **`pick-web` / `account get` / `account list`** 成功:stdout 多为单行 JSON(不一定含 `success` 字段)。
|
||||
- **失败**:优先解析 JSON;否则兼容 `ERROR:` 文本。
|
||||
|
||||
@@ -1,39 +1,212 @@
|
||||
# 与其它 Skill 集成(account-manager)
|
||||
|
||||
## 核心约束
|
||||
|
||||
业务 skill **不能自己散落存储账号、密码、API Key、Token**。应通过 account-manager **CLI**(进程隔离)或 **CLI + `service.rpa_helpers` 同进程库**(浏览器自动化)组合获取。
|
||||
|
||||
## 两种集成方式
|
||||
|
||||
| 方式 | 适用场景 | 调用形态 |
|
||||
|---|---|---|
|
||||
| **A. CLI subprocess(v1 起)** | 任意业务 skill;一次性取账号 JSON / 凭据 / lease | `subprocess.run([sys.executable, f"{skill_root}/scripts/main.py", "account", "pick-web", ...])` |
|
||||
| **B. rpa_helpers 同进程 import(v2)** | 需要 Playwright 上下文内完成登录与页面操作 | `sys.path.insert(0, f"{account_manager_root}/scripts"); from service.rpa_helpers import ensure_logged_in` |
|
||||
|
||||
常见组合:**CLI** 负责 pick / lease / `get-credential --reveal`;**rpa_helpers** 负责 `launch_browser_with_profile` + `ensure_logged_in`。详见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。
|
||||
|
||||
## 推荐调用方式
|
||||
|
||||
使用 **`subprocess`** 调用技能根目录下的 `scripts/main.py`,工作目录可为技能根或 `scripts/`(只要命令中的 `python` 与路径一致)。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`** 必须与最终用户会话一致,否则读写到错误库。
|
||||
使用 **`subprocess`** 调用 `scripts/main.py`。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`**(或上层包装的 `CLAW_*`)必须与最终用户会话一致——否则会写到另一套目录。路径调试见 [`RUNTIME.md`](RUNTIME.md)。
|
||||
|
||||
## 机器可读命令摘要
|
||||
|
||||
| 目的 | 命令 | 成功时 stdout |
|
||||
|------|------|----------------|
|
||||
| 取单条账号 JSON | `get <id>` | 单行 JSON 对象 |
|
||||
| 批量账号 JSON | `list-json <平台或all> [--limit N]` | 单行 JSON 数组 |
|
||||
| 只要已登录 | `pick-logged-in <平台>` | 单行 JSON 对象 |
|
||||
| 自动化候选(优先已登录) | `pick-web <平台>` | 单行 JSON 对象 |
|
||||
| 回写登录态 | `set-login-status <id> <0或1>` | 首行 `OK:...` |
|
||||
| 目的 | 命令 | 成功时 stdout(摘要) |
|
||||
|---|---|---|
|
||||
| 枚举平台 | `platform list [--domain ...]` | `{"success": true, "data": {"platforms": [...]}}` |
|
||||
| 平台详情 | `platform get <p>` | `{"success": true, "data": {...}}` |
|
||||
| 取单条账号 JSON | `account get <id>` | **单行 JSON 对象**(无固定 `success` 字段) |
|
||||
| 批量账号 JSON | `account list ...` | **单行 JSON 数组** |
|
||||
| 网页自动化候选 | `account pick-web ...` | **单行 JSON 对象** |
|
||||
| 取加密凭据明文 | `account get-credential <id> --reveal --lease-token <t>` | **单行 JSON**,含 `plaintext`(若可解密) |
|
||||
| 备份加密凭据 | `account export-credentials --plaintext` | **JSON 数组**(stderr 有警告行) |
|
||||
| 导入加密凭据 | `account import-credentials [--replace-all]` | **通常为空**(计数在 stderr) |
|
||||
| 初始化 master key | `account init-master-key [--print\|--from-env\|--rotate]` | **单行 JSON** |
|
||||
| 新增网页账号 | `account add-web ...` | `{"success": true, "data": {...}}` |
|
||||
| 新增凭据 | `account add-secret ...` | `{"success": true, "data": {...}}` |
|
||||
| 获取 API Key | `credential pick ... [--reveal]` | `{"success": true, ...}` |
|
||||
| 兼容入口 | `get <id>` / `list-json <p>` / `pick-web <p>` | 同上对应新式命令 |
|
||||
|
||||
## 解析协议(强建议)
|
||||
更完整的命令表见 [`CLI.md`](CLI.md)。
|
||||
|
||||
1. 读取子进程 **stdout** 全文或首行。
|
||||
2. 若首行以 **`ERROR:`** 开头 → 失败,不要 `json.loads` 整段 stdout。
|
||||
3. 若首行以 **`OK:`** 开头 → `set-login-status` 成功(可再检查退出码 `0`)。
|
||||
4. **`get` / `list-json` / `pick-logged-in` / `pick-web`**:成功时应对**第一行**做 `json.loads`;后续行应不存在,若有则视为非协议内容。
|
||||
## 解析协议(稳妥做法)
|
||||
|
||||
## JSON 对象字段(与 `get` 一致)
|
||||
1. 读取子进程 **stdout**;若有多行,**集成应以首行为准**(export/import 例外:`export` 整块 stdout 即 JSON;`import` 读 stderr)。
|
||||
2. **优先尝试 `json.loads`**:
|
||||
- 若为 dict 且 `success is False` → 读取 `error.code` / `error.message`。
|
||||
- 若 dict 且 `success is True` 且存在 `data` → 业务字段通常在 `data` 内(`add-web` / `add-secret` / `platform`)。
|
||||
3. 若 JSON 首字段不是上述统一格式(例如 `pick-web`)→ 直接按字段读取。
|
||||
4. **遗留文本失败**:首行以 **`ERROR:`** 开头 → 视为失败(旧别名路径仍可能出现)。
|
||||
|
||||
典型字段见仓库 `assets/examples/get-response.json` 与 `assets/schemas/account-record.schema.json`。
|
||||
## JSON 对象字段
|
||||
|
||||
常见键名包括:`id`, `name`, `platform`, `phone`, `profile_dir`, `url`, `login_status`, `last_login_at`, `created_at`, `updated_at`;`extra_json` 列中的扁平扩展可能并入同一对象。
|
||||
### `account pick-web`(典型)
|
||||
|
||||
## Publisher / 自动化流水线示例
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"platform_key": "maersk",
|
||||
"account_label": "Maersk simulator booking",
|
||||
"login_id": "demo@maersk.com",
|
||||
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
|
||||
"url": "http://localhost:5180/industries/logistics/maersk/login",
|
||||
"tenant_id": "tenant-demo",
|
||||
"environment": "simulator",
|
||||
"role": "booking",
|
||||
"provider_code": "maersk",
|
||||
"session_status": "unknown",
|
||||
"auth_strategy": "qr_code_manual",
|
||||
"session_persistent": 1,
|
||||
"device_fingerprint": null,
|
||||
"lease_token": "…………",
|
||||
"lease_expires_at": "2026-05-11T12:34:56"
|
||||
}
|
||||
```
|
||||
|
||||
1. `pick-web 搜狐号`(或目标平台)→ 解析 JSON → 取 `id`、`profile_dir`、`url`。
|
||||
2. 若需确认已登录:检查 `login_status == 1`,否则可引导用户执行 `login <id>` 或先 `open <id>` 人工登录。
|
||||
3. 外部脚本在确认登录后,可调用 `set-login-status <id> 1` 保持与库一致(若你们自有检测逻辑)。
|
||||
`lease_*` 仅在传入 `--lease` 时出现;时间与仓库 `_unix_to_iso_local` 一致。
|
||||
|
||||
### `account get`
|
||||
|
||||
输出即数据库映射字典(字段与 [`SCHEMA.md`](SCHEMA.md) `accounts` 一致,`extra_json` 等为结构化 JSON)。
|
||||
|
||||
### `credential pick`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"id": 1,
|
||||
"platform_key": "deepseek",
|
||||
"credential_label": "DeepSeek production key",
|
||||
"credential_type": "api_key",
|
||||
"secret_storage": "env",
|
||||
"secret_ref": "DEEPSEEK_API_KEY",
|
||||
"secret_mask": "****7890",
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
仅 `--reveal` 时附加 **`secret`**。请勿向终端用户打印 reveal 结果。
|
||||
|
||||
### `account get-credential`
|
||||
|
||||
成功示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"account_id": 42,
|
||||
"credential_id": 7,
|
||||
"credential_type": "password",
|
||||
"secret_storage": "local_encrypted",
|
||||
"secret_mask": "****abcd",
|
||||
"plaintext": "……"
|
||||
}
|
||||
```
|
||||
|
||||
失败时使用统一 JSON:`{"success": false, "error": {"code": "...", "message": "..."}}`(亦可能夹杂历史 `ERROR:` 文本命令——见 [`ERRORS.md`](ERRORS.md))。
|
||||
|
||||
## rpa_helpers 同进程使用(摘要)
|
||||
|
||||
```python
|
||||
import sys
|
||||
|
||||
AM_ROOT = r"D:\OpenClaw\client-commons\account-manager"
|
||||
sys.path.insert(0, f"{AM_ROOT}/scripts")
|
||||
|
||||
from service.rpa_helpers import (
|
||||
launch_browser_with_profile,
|
||||
close_browser,
|
||||
ensure_logged_in,
|
||||
)
|
||||
```
|
||||
|
||||
**务必**:`sys.path` 指向 **`account-manager/scripts`**,因此 import 形态为 **`from service.rpa_helpers ...`**(而不是 `from scripts.service.rpa_helpers ...`)。
|
||||
|
||||
完整 API、登录判定 hook、`LoginResult` / `AuthStrategyError` 说明见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。
|
||||
|
||||
## 业务流程示例
|
||||
|
||||
### 物流 RPA(CLI)
|
||||
|
||||
1. `account pick-web --platform maersk --environment simulator --role booking --lease --holder my-skill`
|
||||
2. 解析 JSON → 取 `id`、`profile_dir`、`url`、`lease_token`
|
||||
3. 用 `profile_dir` 启动持久化浏览器上下文
|
||||
4. 业务完成后:`lease release <lease_token>`
|
||||
5. 如遇登录失效:`account mark-session <id> --session-status needs_login`
|
||||
|
||||
### LLM API 调用(CLI)
|
||||
|
||||
1. `credential pick --platform deepseek --type api_key --environment production --reveal`
|
||||
2. 解析 JSON → 取 `secret` 字段
|
||||
3. 仅在内存中使用,不写磁盘、不打日志
|
||||
|
||||
### 加密密码 + 自动登录(CLI + rpa_helpers)
|
||||
|
||||
```python
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
AM = r"D:\OpenClaw\client-commons\account-manager"
|
||||
PY = sys.executable
|
||||
MAIN = f"{AM}/scripts/main.py"
|
||||
|
||||
sys.path.insert(0, f"{AM}/scripts")
|
||||
from service.rpa_helpers import launch_browser_with_profile, close_browser, ensure_logged_in
|
||||
|
||||
|
||||
def am_json(argv: list[str]) -> dict:
|
||||
r = subprocess.run([PY, MAIN, *argv], capture_output=True, text=True, check=False)
|
||||
line = (r.stdout or "").splitlines()[0] if r.stdout else ""
|
||||
return json.loads(line)
|
||||
|
||||
|
||||
# 1) pick 账号 + lease
|
||||
acc = am_json(
|
||||
[
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
"icbc_sim",
|
||||
"--lease",
|
||||
"--holder",
|
||||
"my-rpa",
|
||||
]
|
||||
)
|
||||
|
||||
# 2) 取明文密码(必须 reveal + lease)
|
||||
cred = am_json(
|
||||
[
|
||||
"account",
|
||||
"get-credential",
|
||||
str(acc["id"]),
|
||||
"--reveal",
|
||||
"--lease-token",
|
||||
acc["lease_token"],
|
||||
]
|
||||
)
|
||||
|
||||
# 3) 浏览器 + 登录
|
||||
ctx, page = launch_browser_with_profile(acc["profile_dir"])
|
||||
try:
|
||||
result = ensure_logged_in(page, acc, credentials={"plaintext": cred["plaintext"]})
|
||||
if not result.ok:
|
||||
print(result.message, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
# …业务逻辑…
|
||||
finally:
|
||||
close_browser(ctx)
|
||||
subprocess.run([PY, MAIN, "lease", "release", acc["lease_token"]], check=False)
|
||||
```
|
||||
|
||||
## 退出码
|
||||
|
||||
- **`set-login-status`**:失败时进程以 **`1`** 退出(见实现)。
|
||||
- 其它子命令多数为 **`0`**;若需严格编排,请以 **stdout 首行前缀** 与 **JSON 可解析性** 为准,不要仅依赖退出码。
|
||||
请以 **stdout JSON / `ERROR:` 前缀** 为准;不要仅依赖退出码。
|
||||
|
||||
@@ -1,23 +1,101 @@
|
||||
# 平台与别名(account-manager)
|
||||
|
||||
**权威来源**:`scripts/util/platforms.py` 中的 `PLATFORMS`。下表便于 AI/文档阅读;若与代码不一致,**以代码为准**。
|
||||
**权威来源**:`scripts/util/platforms.py` 中的 `PLATFORMS`(当前 **31** 条)。若本文与代码不一致,**以代码为准**。
|
||||
|
||||
入库字段 `accounts.platform` 使用下表 **英文键**(小写)。CLI 与用户话术可使用 **展示名** 或 **别名**。
|
||||
解析函数:`resolve_platform_key(name)` — 支持英文键、`display_name`、任一 alias(大小写不敏感)。
|
||||
|
||||
| 英文键 (`platform`) | 展示名 (`label`) | 默认账号名前缀 (`prefix`) | 别名 (`aliases`) | 默认 URL |
|
||||
|---------------------|------------------|---------------------------|------------------|----------|
|
||||
| `sohu` | 搜狐号 | 搜狐 | 搜狐 | https://mp.sohu.com |
|
||||
| `toutiao` | 头条号 | 头条 | 头条 | https://mp.toutiao.com/ |
|
||||
| `zhihu` | 知乎 | (同 label) | 知乎号 | https://www.zhihu.com |
|
||||
| `wechat` | 微信公众号 | 微信 | 公众号、微信 | https://mp.weixin.qq.com |
|
||||
| `kimi` | Kimi | (同 label) | 月之暗面 | https://kimi.moonshot.cn |
|
||||
| `deepseek` | DeepSeek | (同 label) | — | https://chat.deepseek.com |
|
||||
| `doubao` | 豆包 | (同 label) | — | https://www.doubao.com |
|
||||
| `qianwen` | 通义千问 | 通义 | 通义、千问 | https://tongyi.aliyun.com |
|
||||
| `yiyan` | 文心一言 | 文心 | 文心、一言 | https://yiyan.baidu.com |
|
||||
| `yuanbao` | 腾讯元宝 | 元宝 | 元宝 | https://yuanbao.tencent.com |
|
||||
---
|
||||
|
||||
## 解析规则(摘要)
|
||||
## 物流平台(`domain = logistics`,9)
|
||||
|
||||
- 英文键本身、各 `label`、各 `aliases` 条目(及小写形式)均可解析到对应键。
|
||||
- 无法识别时,相关子命令会输出 `ERROR:INVALID_PLATFORM` 或 `ERROR:INVALID_PLATFORM_LIST` / `INVALID_PLATFORM_LIST_JSON` 等,并附带「支持的平台」提示。
|
||||
| 英文键 | 展示名 | 供应商代码 | 别名(节选) | 默认 URL |
|
||||
|---|---|---|---|---|
|
||||
| `maersk` | Maersk | maersk | 马士基 | https://www.maersk.com |
|
||||
| `cosco` | COSCO | cosco | 中远海运 | (空) |
|
||||
| `msc` | MSC | msc | 地中海航运 | (空) |
|
||||
| `cma_cgm` | CMA CGM | cma_cgm | 达飞 | (空) |
|
||||
| `evergreen` | Evergreen | evergreen | 长荣 | (空) |
|
||||
| `cargo_wise` | CargoWise | cargo_wise | CargoWise, Cargo Wise | (空) |
|
||||
| `freightos` | Freightos | freightos | Freightos | (空) |
|
||||
| `webcargo` | WebCargo | webcargo | WebCargo, web cargo | (空) |
|
||||
| `sinotrans` | Sinotrans | sinotrans | 中外运 | (空) |
|
||||
|
||||
---
|
||||
|
||||
## LLM / AI 平台(`domain = llm`,10)
|
||||
|
||||
其中 `openai`、`anthropic` 在注册表中 **`capabilities.web = false`**,以 API Key 场景为主;其余条目多为 Web + API。
|
||||
|
||||
| 英文键 | 展示名 | 别名(节选) | 默认 URL |
|
||||
|---|---|---|---|
|
||||
| `deepseek` | DeepSeek | DeepSeek | https://chat.deepseek.com |
|
||||
| `doubao` | Doubao | 豆包 | https://www.doubao.com |
|
||||
| `kimi` | Kimi | 月之暗面 | https://kimi.moonshot.cn |
|
||||
| `qianwen` | Qianwen | 通义千问、通义、千问 | https://tongyi.aliyun.com |
|
||||
| `yiyan` | YiYan | 文心一言、文心、一言 | https://yiyan.baidu.com |
|
||||
| `yuanbao` | Yuanbao | 腾讯元宝、元宝 | https://yuanbao.tencent.com |
|
||||
| `gemini` | Gemini | Gemini、谷歌Gemini、Google Gemini、Bard | https://gemini.google.com |
|
||||
| `minimax` | MiniMax | MiniMax | (空) |
|
||||
| `openai` | OpenAI API | OpenAI、GPT、ChatGPT API | https://platform.openai.com |
|
||||
| `anthropic` | Anthropic | Anthropic、Claude API | https://console.anthropic.com |
|
||||
|
||||
---
|
||||
|
||||
## 内容平台(`domain = content`,8)
|
||||
|
||||
| 英文键 | 展示名 | 别名(节选) | 默认 URL |
|
||||
|---|---|---|---|
|
||||
| `sohu` | 搜狐号 | 搜狐、搜狐号 | https://mp.sohu.com |
|
||||
| `toutiao` | 头条号 | 头条、头条号 | https://mp.toutiao.com/ |
|
||||
| `zhihu` | 知乎 | 知乎、知乎号 | https://www.zhihu.com |
|
||||
| `wechat` | 微信公众号 | 公众号、微信、微信公众号 | https://mp.weixin.qq.com |
|
||||
| `douyin` | 抖音 | 抖音、Douyin | https://www.douyin.com |
|
||||
| `wechat_mp` | 微信公众号 | 微信公众号、公众号、WeChat MP | https://mp.weixin.qq.com |
|
||||
| `xiaohongshu` | 小红书 | 小红书、Xiaohongshu、RED | https://www.xiaohongshu.com |
|
||||
| `bilibili` | 哔哩哔哩 | B站、哔哩哔哩、Bilibili | https://www.bilibili.com |
|
||||
|
||||
---
|
||||
|
||||
## 开发控制台(`domain = dev`,3)
|
||||
|
||||
| 英文键 | 展示名 | 别名(节选) | 默认 URL |
|
||||
|---|---|---|---|
|
||||
| `github` | GitHub | GitHub、github | https://github.com/login |
|
||||
| `aws` | AWS | AWS、Amazon Web Services、亚马逊云 | https://console.aws.amazon.com |
|
||||
| `gcp` | GCP | GCP、Google Cloud、谷歌云 | https://console.cloud.google.com |
|
||||
|
||||
---
|
||||
|
||||
## 仿真平台(`domain = sim`,1)
|
||||
|
||||
| 英文键 | 展示名 | 别名(节选) | 默认 URL |
|
||||
|---|---|---|---|
|
||||
| `icbc_sim` | 工行仿真 | 工行仿真、ICBC Simulator、ICBC SIM | http://localhost:5180 |
|
||||
|
||||
---
|
||||
|
||||
## `platform list --domain` 说明
|
||||
|
||||
CLI `platform list --domain` **仅接受** `logistics | llm | content`(见 `cmd_platform_list`)。`dev` / `sim` 域平台请 **省略 `--domain`** 获取完整列表后在 JSON 中筛选,或直接 `platform get <key>`。
|
||||
|
||||
---
|
||||
|
||||
## 内置默认 `auth_strategy`
|
||||
|
||||
`db/platform_defaults.py` 中的 `PLATFORM_DEFAULT_AUTH_STRATEGY` 提供 **17** 个平台的初始映射(迁移时写入 `platforms.default_auth_strategy`,可被数据库手工覆盖):
|
||||
|
||||
| 平台键 | 默认 `auth_strategy` |
|
||||
|---|---|
|
||||
| `maersk`, `cosco`, `msc` | `qr_code_manual` |
|
||||
| `douyin`, `wechat_mp`, `xiaohongshu`, `bilibili`, `toutiao`, `sohu` | `qr_code_manual` |
|
||||
| `github`, `aws`, `gcp` | `password_plus_2fa` |
|
||||
| `icbc_sim` | `password_auto` |
|
||||
| `openai`, `anthropic`, `deepseek`, `doubao` | `api_token` |
|
||||
|
||||
其余注册平台若 DB 中 `default_auth_strategy` 为空,则在创建账号时回落到 `resolve_auth_strategy_for_platform`(内置映射 → 否则 `password_auto`)。业务可在 `add-web` 显式传入 `--auth-strategy`。
|
||||
|
||||
---
|
||||
|
||||
## 平台注册到数据库
|
||||
|
||||
`seed_platforms(conn)` 在业务流程中随 `init_db()` 触发,将 `PLATFORMS` upsert 进 `platforms` 表(仅覆盖定义字段;**不会**自动清空用户自定义的 `default_auth_strategy`,除非迁移逻辑另行更新——详见 [`SCHEMA.md`](SCHEMA.md))。
|
||||
|
||||
@@ -1,13 +1,61 @@
|
||||
# references(account-manager)
|
||||
---
|
||||
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略与 Fernet 加密入库;可选 CLI + rpa_helpers 同进程自动登录。涵盖物流、LLM、内容、开发、仿真等平台。"
|
||||
---
|
||||
|
||||
供 Agent 按需加载的说明文档(与 `SKILL.md` 配合,渐进式披露)。
|
||||
# 🔐 账号与凭据管理
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| [CLI.md](CLI.md) | 子命令、参数、行为说明 |
|
||||
| [PLATFORMS.md](PLATFORMS.md) | 平台英文键、中文名、别名 |
|
||||
| [RUNTIME.md](RUNTIME.md) | 环境变量、数据路径、日志、登录调参 |
|
||||
| [ERRORS.md](ERRORS.md) | `ERROR:` / `OK:` 前缀一览 |
|
||||
| [INTEGRATION.md](INTEGRATION.md) | 跨 Skill 调用与 JSON 解析约定 |
|
||||
把你在各平台的账号与凭据**登记到本地**,后续物流 RPA、大模型写作、内容发布等技能才能自动选用对应环境。
|
||||
|
||||
示例 JSON 与 Schema 见仓库 `assets/`。
|
||||
> 本技能是**唯一**账号/凭据管理入口。业务 skill 不自己散落密码、API Key,而是通过 CLI(或与 CLI 组合的同进程 [`RPA_HELPERS.md`](RPA_HELPERS.md))获取。
|
||||
|
||||
## 它能帮你做什么
|
||||
|
||||
- **添加网页/RPA 账号** — 选平台,给出登录标识(邮箱/手机/用户名),自动创建浏览器 profile 目录。
|
||||
- **添加 API Key/Token / 加密密码** — `none` / `env` / `windows_credential` / `local_encrypted`(Fernet 密文入库)。
|
||||
- **查看/列出账号** — 按平台、环境、角色等多维度筛选。
|
||||
- **给 RPA 技能用** — `pick-web` 按规则挑候选账号,支持 lease 防并发;输出含 `auth_strategy` / `session_persistent` / `device_fingerprint`。
|
||||
- **给 LLM 技能用** — `credential pick` 安全获取 API Key。
|
||||
- **在浏览器里查看** — `open <id>` 打开 Chrome/Edge 查看,仅查看不写库。
|
||||
- **加密存储密码** — Fernet 加密,密码在 SQLite 中以密文形式存在;只有持有 master key 才能解密。
|
||||
- **9 种登录策略** — 账密自动、账密+图形验证码、账密+2FA、扫码、每次手动登录、设备绑定、客户端证书、API Token、SSO 跳转;业务 skill 可调用统一入口 `ensure_logged_in`(见 [`RPA_HELPERS.md`](RPA_HELPERS.md))。
|
||||
- **加密备份与迁移** — `export-credentials --plaintext` 导出 JSON,`import-credentials` 在新机器重新加密写入(需先 `init-master-key`)。
|
||||
|
||||
## 你可以这样对它说
|
||||
|
||||
- 「帮我添加一个 Maersk 模拟器账号,邮箱 demo@maersk.com。」
|
||||
- 「添加 DeepSeek 的 API Key,存在 Windows 凭据管理器里。」
|
||||
- 「列出所有物流平台的账号。」
|
||||
- 「用浏览器打开 3 号账号看看页面。」
|
||||
- 「给 RPA 技能取一个 maersk 模拟器的 booking 账号。」
|
||||
- 「我要把工行仿真账号的密码加密保存,下次 RPA 自动登录用。」
|
||||
- 「给我导出所有加密凭据,明天换台电脑要用。」
|
||||
|
||||
## 你需要提供什么
|
||||
|
||||
| 场景 | 你需要给的 |
|
||||
|---|---|
|
||||
| 添加网页账号 | **平台名称** + **登录标识**(邮箱/手机/用户名) |
|
||||
| 添加 API Key / 加密密码 | **平台名称** + **凭据类型**(api_key 等)+ **secret**(stdin / 环境变量引用等) |
|
||||
| 查看/列出 | 可选:平台名、环境、角色等筛选条件 |
|
||||
| 浏览器打开 | 账号 **id**(需本机已安装 Chrome 或 Edge) |
|
||||
|
||||
## 它不做什么
|
||||
|
||||
- 不在此处包办所有网站的 DOM 自动化细节(具体抓取逻辑在各业务 skill)
|
||||
- 不代替内容管理、发布、大模型调用等业务技能
|
||||
- `windows_credential` / `env` 路径下不把明文写入 SQLite;`local_encrypted` 仅存 Fernet 密文
|
||||
|
||||
## Secret 存储方式
|
||||
|
||||
| 方式 | 说明 |
|
||||
|---|---|
|
||||
| `none` | 无 secret(浏览器 profile 登录态) |
|
||||
| `env` | 引用环境变量名,运行时读取 |
|
||||
| `windows_credential` | 正式推荐:真实 secret 存 Windows Credential Manager |
|
||||
| `local_encrypted` | 本地 Fernet 加密存 DB;需先 `account init-master-key` |
|
||||
|
||||
命令细节见 [`CLI.md`](CLI.md);加密与环境变量见 [`RUNTIME.md`](RUNTIME.md)。
|
||||
|
||||
---
|
||||
|
||||
**作者**:深圳匠厂科技有限公司 · **版本**:2.0.0
|
||||
|
||||
218
references/RPA_HELPERS.md
Normal file
218
references/RPA_HELPERS.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# rpa_helpers 库 — 业务 skill 同进程登录
|
||||
|
||||
`scripts/service/rpa_helpers/` 是 account-manager v2 提供给业务 skill 的 **同进程 Python 库**。业务代码调用 **`ensure_logged_in`**,库按账号记录的 **`auth_strategy`** 分发到对应登录流程(底层基于 Playwright)。
|
||||
|
||||
## 与 CLI 的关系
|
||||
|
||||
| 路径 | 职责 |
|
||||
|---|---|
|
||||
| **CLI subprocess** | 获取结构化账号 JSON、租约、`get-credential --reveal` 明文密码;进程隔离、易于跨语言 |
|
||||
| **rpa_helpers** | 在已有 `BrowserContext`/`Page` 上执行登录编排与人类化输入 |
|
||||
|
||||
推荐组合:**CLI → pick / lease → get-credential → `launch_browser_with_profile` + `ensure_logged_in`**。
|
||||
|
||||
## 集成方式
|
||||
|
||||
### Import 路径
|
||||
|
||||
```python
|
||||
import sys
|
||||
|
||||
AM_ROOT = r"D:\OpenClaw\client-commons\account-manager"
|
||||
sys.path.insert(0, f"{AM_ROOT}/scripts")
|
||||
|
||||
from service.rpa_helpers import (
|
||||
AuthStrategyError,
|
||||
LoginResult,
|
||||
close_browser,
|
||||
ensure_logged_in,
|
||||
launch_browser_with_profile,
|
||||
)
|
||||
```
|
||||
|
||||
**务必**:`sys.path` 追加的是 **`…/account-manager/scripts`**,因此模块命名空间为 `service.rpa_helpers`(**不要**写成 `from scripts.service.rpa_helpers …`)。
|
||||
|
||||
### 运行时依赖
|
||||
|
||||
- Python **3.10+**
|
||||
- 已安装 **`playwright`** Python 包(宿主 runtime 提供)
|
||||
- 本机已安装 **Chrome 或 Edge**(启动时使用 channel 模式,优先 Chrome,失败回落 Edge——见 `service.browser_service.resolve_chromium_channel`)
|
||||
|
||||
文档不提供 `pip install …` 指令;版本由匠厂宿主统一管理。
|
||||
|
||||
---
|
||||
|
||||
## 公开 API 一览
|
||||
|
||||
下列符号由 `service/rpa_helpers/__init__.py` **导出(共 19 个名字)**:
|
||||
|
||||
| 分类 | 符号 |
|
||||
|---|---|
|
||||
| **dispatcher + 策略函数** | `ensure_logged_in`,以及 `ensure_logged_in_password_auto`、`ensure_logged_in_password_with_captcha`、`ensure_logged_in_password_plus_2fa`、`ensure_logged_in_qr_code_manual`、`ensure_logged_in_per_session_manual`、`ensure_logged_in_device_bound`、`ensure_logged_in_client_certificate`、`ensure_logged_in_api_token`、`ensure_logged_in_sso_redirect` |
|
||||
| **结果 / 异常** | `LoginResult`,`AuthStrategyError` |
|
||||
| **浏览器** | `launch_browser_with_profile`,`close_browser` |
|
||||
| **人类化输入** | `pause`,`human_type`,`human_click`,`human_select`,`human_scroll_to` |
|
||||
|
||||
---
|
||||
|
||||
## `browser` 模块
|
||||
|
||||
### `launch_browser_with_profile(profile_dir, *, channel=None, headless=False, extra_args=None, locale="zh-CN") -> tuple[BrowserContext, Page]`
|
||||
|
||||
- 创建持久化上下文(`user_data_dir=profile_dir`),注入可选 stealth 脚本。
|
||||
- `channel` 默认为宿主检测结果(Chrome 优先)。
|
||||
- **必须在 `finally` 中调用 `close_browser(context)`**,否则 playwright driver 可能泄漏。
|
||||
|
||||
### `close_browser(ctx) -> None`
|
||||
|
||||
关闭上下文并停止内部的 Playwright session manager。
|
||||
|
||||
---
|
||||
|
||||
## `human` 模块(人类化操作)
|
||||
|
||||
| 函数 | 行为 |
|
||||
|---|---|
|
||||
| `pause(min_ms=200, max_ms=800)` | 随机等待 |
|
||||
| `human_type(locator, text, *, per_char_ms_range=(50, 200), pre_pause=True)` | 滚动进入视图 → 停顿 → `click` → 逐字符 `type` |
|
||||
| `human_click(locator)` | 滚动 → 停顿 → `click` |
|
||||
| `human_select(locator, value_or_label)` | 滚动 → 停顿 → `select_option`(依次尝试 value / label) |
|
||||
| `human_scroll_to(locator)` | 平滑滚动到元素 |
|
||||
|
||||
_locator_ 均为 Playwright `Locator`。
|
||||
|
||||
---
|
||||
|
||||
## `auth_flows` 模块
|
||||
|
||||
### `ensure_logged_in(page, account, *, credentials=None, **kwargs) -> LoginResult`
|
||||
|
||||
1. 读取 `account["auth_strategy"]`。为空字符串 → 抛出 `AuthStrategyError(ERR_AUTH_STRATEGY_NOT_SUPPORTED)`。
|
||||
2. 若策略需要密码则要求 `credentials["plaintext"]`;缺失 → `ERR_CREDENTIAL_MISSING`。
|
||||
3. 分派到对应 `ensure_logged_in_*`。
|
||||
4. 未知策略 → `AuthStrategyError(ERR_AUTH_STRATEGY_NOT_SUPPORTED)`。
|
||||
|
||||
附加 kwargs 会透传给具体策略(如 `captcha_value`、`wait_human_sec`)。
|
||||
|
||||
### `LoginResult`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `ok` | `bool` | 流程是否达到判定条件 |
|
||||
| `needs_human` | `bool` | 是否需要人工协助(扫码 / OTP / SSO 等) |
|
||||
| `message` | `str` | 描述信息(便于日志) |
|
||||
|
||||
### `AuthStrategyError`
|
||||
|
||||
属性:`code: str`,`message: str`。错误码与 CLI / [`ERRORS.md`](ERRORS.md) 对齐。
|
||||
|
||||
### 九个 `ensure_logged_in_*` 入口
|
||||
|
||||
业务代码通常只调用 `ensure_logged_in`。如需针对性 reuse,可直接 import `auth_flows` 中的同名函数;签名见源码 `scripts/service/rpa_helpers/auth_flows.py`。
|
||||
|
||||
---
|
||||
|
||||
## 已登录检测(`_is_already_logged_in`)
|
||||
|
||||
核心逻辑:
|
||||
|
||||
1. 读取当前 `page.url`;空白页 → **False**。
|
||||
2. 仅在账号 `extra_json`(可为 dict 或 JSON 字符串)中出现以下 hook 之一才可能返回 True:
|
||||
- **`logged_in_url_pattern`**:正则,匹配当前 URL。
|
||||
- **`logged_in_selector`**:CSS/XPath selector,`locator.first.is_visible(timeout=500)`。
|
||||
3. 若无任一 hook → **默认 False** → **除非已有持久化 session**,否则会完整跑登录流程。
|
||||
|
||||
### 接入建议
|
||||
|
||||
1. 首次上线:`auth_strategy=qr_code_manual` + 无 hook → 用户扫码登录。
|
||||
2. 登录成功后记录典型 URL 片段或 DOM selector。
|
||||
3. 写入账号 `extra_json`,例如:
|
||||
|
||||
```python
|
||||
{
|
||||
"logged_in_url_pattern": r"/user/",
|
||||
"logged_in_selector": "div.avatar-wrapper"
|
||||
}
|
||||
```
|
||||
|
||||
4. 下次启动 → `_is_already_logged_in` 命中 → `needs_human` 可能降低。
|
||||
|
||||
### `per_session_manual` 的差异
|
||||
|
||||
该策略 **开场不会调用 `_is_already_logged_in`**,总是导航到业务 URL 并等待人工完成登录(适合强制会话)。
|
||||
|
||||
---
|
||||
|
||||
## 设备绑定策略(`device_bound`)
|
||||
|
||||
如账号设置了 `device_fingerprint`,库会使用 `_current_machine_fingerprint()`(hostname + MAC 派生)比对;不一致抛出 **`ERR_DEVICE_FINGERPRINT_MISMATCH`**。
|
||||
|
||||
---
|
||||
|
||||
## 客户端证书策略(`client_certificate`)
|
||||
|
||||
直接抛出 **`ERR_AUTH_STRATEGY_NOT_AUTOMATABLE`**;请在宿主机手动装载证书后使用其它路径衔接。
|
||||
|
||||
---
|
||||
|
||||
## 完整示例(CLI + rpa_helpers)
|
||||
|
||||
```python
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
AM = r"D:\OpenClaw\client-commons\account-manager"
|
||||
PY = sys.executable
|
||||
MAIN = f"{AM}/scripts/main.py"
|
||||
|
||||
sys.path.insert(0, f"{AM}/scripts")
|
||||
from service.rpa_helpers import launch_browser_with_profile, close_browser, ensure_logged_in
|
||||
|
||||
|
||||
def am_json(argv: list[str]) -> dict:
|
||||
proc = subprocess.run([PY, MAIN, *argv], capture_output=True, text=True, check=False)
|
||||
line = (proc.stdout or "").splitlines()[0]
|
||||
return json.loads(line)
|
||||
|
||||
|
||||
acc = am_json(
|
||||
["account", "pick-web", "--platform", "icbc_sim", "--lease", "--holder", "monitor-rates"]
|
||||
)
|
||||
|
||||
cred = am_json(
|
||||
[
|
||||
"account",
|
||||
"get-credential",
|
||||
str(acc["id"]),
|
||||
"--reveal",
|
||||
"--lease-token",
|
||||
acc["lease_token"],
|
||||
]
|
||||
)
|
||||
|
||||
plaintext = cred.get("plaintext")
|
||||
if plaintext in (None, ""):
|
||||
raise RuntimeError("no plaintext returned; check secret_storage / lease")
|
||||
|
||||
ctx, page = launch_browser_with_profile(acc["profile_dir"])
|
||||
try:
|
||||
result = ensure_logged_in(page, acc, credentials={"plaintext": plaintext})
|
||||
if not result.ok:
|
||||
raise RuntimeError(result.message)
|
||||
# …业务代码…
|
||||
finally:
|
||||
close_browser(ctx)
|
||||
subprocess.run([PY, MAIN, "lease", "release", acc["lease_token"]], check=False)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试建议
|
||||
|
||||
- account-manager 仓库内置 **107** 个 pytest(截至 v2.0.0 文档修订时),覆盖 CLI / schema / rpa_helpers mock 路径。
|
||||
- 业务 skill:
|
||||
- **单元测试**:patch `ensure_logged_in` → 返回 `LoginResult(ok=True, …)`。
|
||||
- **端到端**:在 Windows + 真浏览器环境标记 `@pytest.mark.integration`,禁止在 CI 泄露明文密码。
|
||||
|
||||
更多 CLI 细节参见 [`CLI.md`](CLI.md)、[`INTEGRATION.md`](INTEGRATION.md)。
|
||||
@@ -1,54 +1,81 @@
|
||||
# 运行环境与数据路径(account-manager)
|
||||
|
||||
## 数据落盘位置(当前实现)
|
||||
## 数据落盘位置
|
||||
|
||||
代码见 `scripts/util/runtime_paths.py`、`scripts/util/constants.py`。
|
||||
`get_data_root()` / `get_user_id()` 来自 **`scripts/jiangchang_skill_core/runtime_env.py`**(兼容 **`JIANGCHANG_*` / `CLAW_*` 别名**);`scripts/util/runtime_paths.py` 在此基础上拼接技能子目录。
|
||||
|
||||
- **数据根目录** `get_data_root()`:优先环境变量 **`JIANGCHANG_DATA_ROOT`**;未设置时 Windows 默认 `D:\jiangchang-data`,其它系统默认 `~/.jiangchang-data`。
|
||||
- **用户/工作空间 id** `get_user_id()`:优先 **`JIANGCHANG_USER_ID`**;未设置时为 **`_anon`**。
|
||||
- **数据根目录**:默认读取宿主注入变量;开发模式下未注入时可能落到 Windows `D:\jiangchang-data` 等(详见 runtime_env 实现)。
|
||||
- **用户 / 工作空间 id**:默认 `_anon`(以实际注入为准)。
|
||||
- **技能数据目录**:`{数据根}/{用户id}/account-manager/`
|
||||
- **SQLite**:`account-manager.db`
|
||||
- **Profile**:`profiles/<平台展示名>/<手机号>/`(由 `get_default_profile_dir` 生成)
|
||||
- **日志目录**:`logs/`(见下)
|
||||
|
||||
> OpenClaw 工作区文档中的 **`CLAW_*`** 变量:本技能**当前仍以 `JIANGCHANG_*` 为准**读取数据根与用户 id;若宿主已统一注入 `CLAW_*`,需在代码层做别名兼容(未做前请只依赖本文列出的变量)。
|
||||
- **Master Key**:`master.key`(仅当执行过 `account init-master-key` 且未仅用环境变量场景)
|
||||
- **Profile**:`profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix}]/`
|
||||
- **日志**:与其它技能共用 `{数据根}/{用户id}/logs/jiangchang.log`
|
||||
|
||||
## 调试:为什么「list 没数据」
|
||||
|
||||
- 本地终端未设置 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID` 时,会落到 **默认盘路径** 与 **`_anon`**,与网关中运行的实例**不是同一个库文件**。
|
||||
- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`**(`1`/`true`/`yes`/`on`)后,每次 CLI 子命令前会在 **stderr** 打印实际使用的根目录、用户 id 与数据库路径(由 `_maybe_print_paths_debug_cli` 输出)。
|
||||
- 若终端与网关注入的数据根 / 用户 id 不一致,会写到**不同目录**。
|
||||
- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`**(或宿主等价开关)后,CLI 会在 **stderr** 打印实际路径。
|
||||
|
||||
## 本地 CLI 开发注入(慎用)
|
||||
## 本地 CLI 默认值
|
||||
|
||||
`util/constants.py` 中 **`_apply_cli_local_dev_env()`** 仅在 CLI 入口调用:
|
||||
各技能 `main.py` 调用 `apply_cli_local_defaults()`:
|
||||
|
||||
- 若开启本地开发开关(代码内 `_ACCOUNT_MANAGER_CLI_LOCAL_DEV` 或环境变量 **`JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV=1`**),且当前未设置 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`,会注入代码内配置的默认路径与用户 id。
|
||||
- **发版前务必关闭**,避免个人路径进入制品。详见该文件内注释。
|
||||
- `CLI_LOCAL_DEV_ENABLED = True` 时,未设置环境变量则注入平台默认值。
|
||||
- 发版前改为 `False` 或依赖宿主注入。
|
||||
|
||||
## Secret 存储
|
||||
|
||||
| 方式 | 说明 | 适用场景 |
|
||||
|---|---|---|
|
||||
| `none` | 不存 secret | 浏览器 profile 登录态 |
|
||||
| `env` | SQLite 存变量名,运行时从 `os.environ` 读取 | 轻量测试 |
|
||||
| `windows_credential` | secret 存 Windows Credential Manager | Windows 本地部署 |
|
||||
| `local_encrypted` | Fernet 密文存 SQLite(`secret_ciphertext`),密钥来自 master key | RPA 密码落地;需先 `account init-master-key` |
|
||||
|
||||
- **禁止**把明文写入日志。
|
||||
- `credential pick --reveal` / `credential resolve --reveal` 仍会输出明文 secret——仅限受控环境。
|
||||
|
||||
## Master Key
|
||||
|
||||
| 变量 | 作用 |
|
||||
|---|---|
|
||||
| `ACCOUNT_MANAGER_MASTER_KEY` | Fernet key(典型为 url-safe Base64 **44** 字符);**优先级高于** `master.key` 文件 |
|
||||
|
||||
**加载优先级(解密路径)**:环境变量 → `master.key` 文件 → `ERR_MASTER_KEY_MISSING`。
|
||||
|
||||
**安全建议**
|
||||
|
||||
- 生产环境优先 **环境变量 / Secret Manager** 注入,不在磁盘留 `master.key`。
|
||||
- 桌面环境可使用文件(默认尽力写入私有权限)。
|
||||
- 备份 master key 至密码管理器;**丢失将导致所有 `local_encrypted` 凭据不可解密**。
|
||||
|
||||
详见 [`CLI.md`](CLI.md)(`init-master-key`)、[`ERRORS.md`](ERRORS.md)。
|
||||
|
||||
## 日志
|
||||
|
||||
`scripts/util/logging_config.py`:
|
||||
|
||||
| 变量 | 作用 |
|
||||
|------|------|
|
||||
|---|---|
|
||||
| `JIANGCHANG_LOG_LEVEL` | 日志级别,默认 `INFO` |
|
||||
| `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 等时,WARNING 及以上同步 stderr |
|
||||
| `JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE` | 覆盖日志文件绝对路径(默认 `{技能数据目录}/logs/account-manager.log`) |
|
||||
| `JIANGCHANG_LOG_FILE` | 覆盖主日志文件绝对路径 |
|
||||
| `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` |
|
||||
| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入 |
|
||||
|
||||
## 登录 / 浏览器子进程(login)
|
||||
## 浏览器(`account open` / rpa_helpers)
|
||||
|
||||
`service/browser_service.py` 与子进程 `service/login_child_runner.py`:
|
||||
- 本机需安装 Chrome 或 Edge。
|
||||
- Python 侧依赖 **`playwright`**(匠厂共享 runtime 已内置)。
|
||||
- 使用 **channel 模式**(`chrome` / `msedge`),**不下载 Chromium**。
|
||||
- **不要执行** `playwright install chromium`。
|
||||
|
||||
| 变量 | 作用 |
|
||||
|------|------|
|
||||
| `JIANGCHANG_LOGIN_TIMEOUT_SECONDS` | 登录检测最长秒数,默认 `300`,不少于 `60` |
|
||||
| `JIANGCHANG_LOGIN_POLL_INTERVAL_SECONDS` | DOM 轮询间隔,默认 `1.5`,限制在 `0.5..10` |
|
||||
| `JIANGCHANG_LOGIN_DOM_GRACE_SECONDS` | 打开 URL 后等待再检测,默认 `4`,减轻跳转误判 |
|
||||
### 关键浏览器参数(摘录)
|
||||
|
||||
依赖:**本机已安装 Chrome 或 Edge**;Python 环境需 **`playwright`** 且已执行 `playwright install chromium`(错误信息见 CLI 输出)。
|
||||
- `headless=False`
|
||||
- `no_viewport=True`
|
||||
- `args` 包含 `--start-maximized`
|
||||
- stealth 是否启用由 `OPENCLAW_PLAYWRIGHT_STEALTH` 控制(见 `launch_browser_with_profile`)
|
||||
|
||||
## 常见问题(简要)
|
||||
### rpa_helpers
|
||||
|
||||
- **`ERROR:需要 playwright`**:安装 `playwright` 并安装浏览器内核。
|
||||
- **`ERROR:PROFILE_DIR_MISSING`**:库中该账号 `profile_dir` 为空,需检查数据或重新添加账号流程。
|
||||
- **登录检测一直失败**:关闭占用同一 profile 目录的其它浏览器;可调高超时与环境变量;查看技能日志文件。
|
||||
登录编排封装见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。
|
||||
|
||||
144
references/SCHEMA.md
Normal file
144
references/SCHEMA.md
Normal 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 token(v2.1 增加) |
|
||||
| `expires_at` | INTEGER | Unix 秒 |
|
||||
| `status` | TEXT | `active` / `disabled` / … |
|
||||
| `last_used_at` | INTEGER | Unix 秒 |
|
||||
| `extra_json` | TEXT | JSON |
|
||||
| `created_at` | INTEGER | Unix 秒 |
|
||||
| `updated_at` | INTEGER | Unix 秒 |
|
||||
|
||||
**原则**:不把 **明文** 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)。
|
||||
43
references/V1_COMPATIBILITY.md
Normal file
43
references/V1_COMPATIBILITY.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# v1 调用方兼容承诺(account-manager v2)
|
||||
|
||||
account-manager v2 **不破坏**下列面向脚本 / 其它仓库(通过 CLI subprocess 集成)的契约。
|
||||
|
||||
## CLI 命令
|
||||
|
||||
下列命令的**子命令名与既有必填参数**保持不变(v2 仅 **扩展** 可选参数,不移除):
|
||||
|
||||
- `account pick-web` / `lease release` / `account mark-used` / `account mark-session` / `account mark-error`
|
||||
- `account get` / `account list` / `account add-web` / `account add-secret`
|
||||
- 兼容入口:`get`、`list-json`、`pick-web`、`add`、`delete`、`open`
|
||||
|
||||
加密相关:`account init-master-key` / `account export-credentials` / `account import-credentials`(**仅新增**,不影响旧调用方)。
|
||||
|
||||
内置 `--help` / 无参数打印的 `_USAGE` 文案见仓库 `scripts/cli/app.py`(阶段 F 已与路由对齐)。
|
||||
|
||||
## JSON 字段
|
||||
|
||||
- **`account pick-web`**:成功时为 **单层 JSON 对象**(**无** `success`/`data` 外层)。保留字段包括但不限于:`id`、`platform_key`、`environment`、`role`、`profile_dir`、`lease_token`、`lease_expires_at`(带 `--lease` 时)。v2 **允许追加**字段(如 `auth_strategy`、`session_persistent`、`device_fingerprint`),**不得改名或删除**上述 v1 字段。
|
||||
- **`account get`**:仍为单行账号 JSON(字段集合见 [SCHEMA.md](SCHEMA.md))。
|
||||
- **`lease release` / `account mark-*`**:成功时使用 `_ok_json`(`{"success": true, "data": {...}}`);失败为统一错误 JSON。
|
||||
|
||||
## 错误码
|
||||
|
||||
下列 **`error.code` 字面值**保持不变:
|
||||
|
||||
- `ERROR:NO_ACCOUNT`
|
||||
- `ERROR:LEASE_CONFLICT`
|
||||
- `ERROR:ACCOUNT_NOT_FOUND`
|
||||
- `ERROR:INVALID_PLATFORM`
|
||||
- `ERROR:ACCOUNT_DISABLED`
|
||||
- `ERROR:DB_ERROR`
|
||||
|
||||
v2 新增的 `ERR_*` 代码(如 `ERR_LEASE_INVALID`、`ERR_AUTH_STRATEGY_NOT_SUPPORTED`)仅出现在 **v2 新命令**路径,不应干扰上述 monitor 命令的失败解析。
|
||||
|
||||
## stderr
|
||||
|
||||
v2 可能在 stderr 打印迁移摘要、`[export] WARNING` 等。**调用方应只解析 stdout**,并与 monitor 一样优先取 **最后一行非空行** 做 `json.loads`(见集成测试 `tests/test_v1_compatibility.py`)。
|
||||
|
||||
## 集成验证
|
||||
|
||||
- 仓库内 **`tests/test_v1_compatibility.py`** 以 subprocess 覆盖典型调用序列与 stdout 形态。
|
||||
- 已知调用方:**`monitor-competitor-rates`**(物流仓库)—— 仅 CLI,无 Python import。
|
||||
274
release.ps1
274
release.ps1
@@ -1,23 +1,269 @@
|
||||
# Vendored from jiangchang-platform-kit `tools/release.ps1`.
|
||||
# Source: https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit/-/blob/main/tools/release.ps1
|
||||
# The PyPI wheel does not ship this script; sync periodically when upstream workflow changes.
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
One-command release script for skill repos.
|
||||
|
||||
.DESCRIPTION
|
||||
- Optional auto-commit
|
||||
- Push current branch
|
||||
- Auto-increment semantic tag (vX.Y.Z)
|
||||
- Create & push tag
|
||||
- Fail fast on unsafe states
|
||||
|
||||
.EXAMPLES
|
||||
# Safe mode (recommended): requires clean working tree
|
||||
.\release.ps1
|
||||
|
||||
# Auto commit tracked/untracked changes then release
|
||||
.\release.ps1 -AutoCommit -CommitMessage "chore: update skill config"
|
||||
|
||||
# Dry run (show what would happen)
|
||||
.\release.ps1 -DryRun
|
||||
|
||||
# Custom tag prefix
|
||||
.\release.ps1 -Prefix "v" -Message "正式发布"
|
||||
|
||||
.NOTES
|
||||
Requires: git, PowerShell 5+
|
||||
|
||||
加密与 ZIP 内容由 CI 工作流 reusable-release-skill.yaml 的「Encrypt Source Code」步骤执行。CI 会:
|
||||
- 加密 scripts/(递归 PyArmor -r,输出到包内 scripts/,与源码目录树一致)
|
||||
- 复制 SKILL.md
|
||||
- 复制 references/(排除 REQUIREMENTS.md)
|
||||
- 复制 assets/
|
||||
- 复制 tests/
|
||||
- 复制 evals/(除 scripts 外均为明文;若目录不存在则跳过;复制时排除常见缓存与运行产物)
|
||||
本脚本在打 tag 前会做一次 scripts/ 结构自检,避免子目录未提交却仍发布。
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Prefix = "v",
|
||||
[string]$Message = "正式发布",
|
||||
[switch]$AutoCommit,
|
||||
[switch]$RequireClean,
|
||||
[string]$CommitMessage,
|
||||
[switch]$DryRun
|
||||
[string]$Prefix = "v",
|
||||
[string]$Message = "正式发布",
|
||||
[switch]$AutoCommit,
|
||||
[switch]$RequireClean,
|
||||
[string]$CommitMessage,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1"
|
||||
$sharedScript = [System.IO.Path]::GetFullPath($sharedScript)
|
||||
|
||||
if (-not (Test-Path $sharedScript)) {
|
||||
throw "Shared release script not found: $sharedScript"
|
||||
function Invoke-Git {
|
||||
param([Parameter(Mandatory = $true)][string]$Args)
|
||||
Write-Host ">> git $Args" -ForegroundColor DarkGray
|
||||
# 将 git 的 stderr 合并进 stdout,避免 PowerShell 在 $ErrorActionPreference=Stop 下
|
||||
# 把 "remote: ..." / "warning: LF -> CRLF" 等非错误输出误判为异常。
|
||||
& cmd /c "git $Args 2>&1"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git command failed: git $Args"
|
||||
}
|
||||
}
|
||||
|
||||
& $sharedScript @PSBoundParameters
|
||||
exit $LASTEXITCODE
|
||||
function Get-GitOutput {
|
||||
param([Parameter(Mandatory = $true)][string]$Args)
|
||||
$output = & cmd /c "git $Args" 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git command failed: git $Args"
|
||||
}
|
||||
return @($output)
|
||||
}
|
||||
|
||||
function Test-Repo {
|
||||
& git rev-parse --is-inside-work-tree *> $null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
|
||||
function Get-CurrentBranch {
|
||||
$b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim()
|
||||
return $b
|
||||
}
|
||||
|
||||
function Get-StatusPorcelain {
|
||||
$lines = @(Get-GitOutput "status --porcelain")
|
||||
return $lines
|
||||
}
|
||||
|
||||
function Parse-SemVerTag {
|
||||
param(
|
||||
[string]$Tag,
|
||||
[string]$TagPrefix
|
||||
)
|
||||
$escaped = [regex]::Escape($TagPrefix)
|
||||
$m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$")
|
||||
if (-not $m.Success) { return $null }
|
||||
|
||||
return [pscustomobject]@{
|
||||
Raw = $Tag
|
||||
Major = [int]$m.Groups[1].Value
|
||||
Minor = [int]$m.Groups[2].Value
|
||||
Patch = [int]$m.Groups[3].Value
|
||||
}
|
||||
}
|
||||
|
||||
function Get-NextTag {
|
||||
param([string]$TagPrefix)
|
||||
|
||||
$tags = Get-GitOutput "tag --list"
|
||||
$parsed = @()
|
||||
|
||||
foreach ($t in $tags) {
|
||||
$t = $t.Trim()
|
||||
if (-not $t) { continue }
|
||||
$obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix
|
||||
if ($null -ne $obj) { $parsed += $obj }
|
||||
}
|
||||
|
||||
if ($parsed.Count -eq 0) {
|
||||
return "${TagPrefix}1.0.1"
|
||||
}
|
||||
|
||||
$latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1
|
||||
return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)"
|
||||
}
|
||||
|
||||
function Assert-SkillReleasePackagingSources {
|
||||
param([Parameter(Mandatory = $true)][string]$SkillRoot)
|
||||
|
||||
$scriptsDir = Join-Path $SkillRoot "scripts"
|
||||
$mainPy = Join-Path $scriptsDir "main.py"
|
||||
if (-not (Test-Path -LiteralPath $mainPy)) {
|
||||
return
|
||||
}
|
||||
|
||||
$text = Get-Content -LiteralPath $mainPy -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
|
||||
if ([string]::IsNullOrWhiteSpace($text)) {
|
||||
return
|
||||
}
|
||||
|
||||
$need = @()
|
||||
if ($text -match '(?m)^\s*from\s+cli\.') { $need += 'cli' }
|
||||
if ($text -match '(?m)^\s*import\s+cli\b') { $need += 'cli' }
|
||||
if ($text -match '(?m)^\s*from\s+service\.') { $need += 'service' }
|
||||
if ($text -match '(?m)^\s*import\s+service\b') { $need += 'service' }
|
||||
if ($text -match '(?m)^\s*from\s+db\.') { $need += 'db' }
|
||||
if ($text -match '(?m)^\s*import\s+db\b') { $need += 'db' }
|
||||
if ($text -match '(?m)^\s*from\s+util\.') { $need += 'util' }
|
||||
if ($text -match '(?m)^\s*import\s+util\b') { $need += 'util' }
|
||||
|
||||
foreach ($p in ($need | Select-Object -Unique)) {
|
||||
$folder = Join-Path $scriptsDir $p
|
||||
if (-not (Test-Path -LiteralPath $folder)) {
|
||||
throw "Release check failed: scripts/main.py imports from '$p' but folder is missing: $folder"
|
||||
}
|
||||
}
|
||||
|
||||
$pyFiles = @(Get-ChildItem -LiteralPath $scriptsDir -Filter *.py -Recurse -File -ErrorAction SilentlyContinue)
|
||||
Write-Host "Packaging check: $($pyFiles.Count) Python file(s) under scripts/ (CI will obfuscate all recursively)." -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
function Ensure-CleanOrAutoCommit {
|
||||
param(
|
||||
[switch]$DoAutoCommit,
|
||||
[switch]$NeedClean,
|
||||
[switch]$IsDryRun,
|
||||
[string]$Msg
|
||||
)
|
||||
|
||||
$status = @(Get-StatusPorcelain)
|
||||
if ($status.Length -eq 0) { return }
|
||||
|
||||
if ($NeedClean) {
|
||||
Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow
|
||||
& git status --short
|
||||
throw "Abort: dirty working tree."
|
||||
}
|
||||
|
||||
# 默认一键发布:有改动就自动提交;也可用 -AutoCommit 显式开启
|
||||
$commitMsg = $Msg
|
||||
if ([string]::IsNullOrWhiteSpace($commitMsg)) {
|
||||
$commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))"
|
||||
}
|
||||
|
||||
if (-not $DoAutoCommit) {
|
||||
Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
if ($IsDryRun) {
|
||||
Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow
|
||||
Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
|
||||
Invoke-Git "add -A"
|
||||
Invoke-Git "commit -m `"$commitMsg`""
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host "=== Release Script Start ===" -ForegroundColor Cyan
|
||||
|
||||
if (-not (Test-Repo)) {
|
||||
throw "Current directory is not a git repository."
|
||||
}
|
||||
|
||||
$branch = Get-CurrentBranch
|
||||
if ([string]::IsNullOrWhiteSpace($branch)) {
|
||||
throw "Unable to determine current branch."
|
||||
}
|
||||
|
||||
if ($branch -notin @("main", "master")) {
|
||||
throw "Current branch is '$branch'. Release is only allowed from main/master."
|
||||
}
|
||||
|
||||
Invoke-Git "fetch --tags --prune origin"
|
||||
|
||||
Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage
|
||||
|
||||
$skillRoot = (Get-GitOutput "rev-parse --show-toplevel" | Select-Object -First 1).Trim()
|
||||
if (Test-Path -LiteralPath (Join-Path $skillRoot "SKILL.md")) {
|
||||
Assert-SkillReleasePackagingSources -SkillRoot $skillRoot
|
||||
}
|
||||
|
||||
# 用 cmd /c + 2>&1 将 stderr 合并进 stdout,避免 PowerShell 在 $ErrorActionPreference=Stop
|
||||
# 下把首次发布时 "fatal: ambiguous argument '@{u}'" 之类的 stderr 误判为异常。
|
||||
$upstream = & cmd /c 'git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>&1'
|
||||
$hasUpstream = ($LASTEXITCODE -eq 0)
|
||||
|
||||
if ($DryRun) {
|
||||
if ($hasUpstream) {
|
||||
Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
if ($hasUpstream) {
|
||||
Invoke-Git "push"
|
||||
} else {
|
||||
Invoke-Git "push -u origin $branch"
|
||||
}
|
||||
}
|
||||
|
||||
$nextTag = Get-NextTag -TagPrefix $Prefix
|
||||
Write-Host "Next tag: $nextTag" -ForegroundColor Green
|
||||
|
||||
$existing = @(Get-GitOutput "tag --list `"$nextTag`"")
|
||||
if ($existing.Length -gt 0) {
|
||||
throw "Tag already exists: $nextTag"
|
||||
}
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow
|
||||
Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow
|
||||
Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan
|
||||
exit 0
|
||||
}
|
||||
|
||||
Invoke-Git "tag -a $nextTag -m `"$Message`""
|
||||
Invoke-Git "push origin $nextTag"
|
||||
|
||||
Write-Host "Release success: $nextTag" -ForegroundColor Green
|
||||
Write-Host "=== Release Script Done ===" -ForegroundColor Cyan
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
7
requirements-platform-kit.txt
Normal file
7
requirements-platform-kit.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
# Optional: install shared SDK from Gitea Package Registry (matches vendored scripts/jiangchang_skill_core at release time).
|
||||
#
|
||||
# pip install -r requirements-platform-kit.txt ^
|
||||
# --index-url https://git.jc2009.com/api/packages/client-jiangchang/pypi/simple/ ^
|
||||
# --extra-index-url https://pypi.org/simple
|
||||
#
|
||||
jiangchang-platform-kit>=0.1.0
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# 公共依赖由宿主共享 runtime 提供;这里只声明技能特有 Python 依赖。
|
||||
# Fernet 加密(local_encrypted 凭据、master key、CLI 启动 import)依赖此包。
|
||||
cryptography>=42.0.0,<45
|
||||
@@ -1,125 +1,356 @@
|
||||
"""CLI 入口:用法说明与 argv 分发。"""
|
||||
import sys
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
CLI entry point: argv dispatch for account-manager v2.
|
||||
|
||||
Subcommand tree:
|
||||
platform list/get/ensure
|
||||
account add-web|add-secret|get|get-credential|list|pick-web|mark-session|mark-error|mark-used|open
|
||||
init-master-key|export-credentials|import-credentials
|
||||
credential pick|resolve
|
||||
lease release|list|cleanup
|
||||
(legacy) add|list|get|list-json|pick-web|open|delete
|
||||
|
||||
Machine commands output single-line JSON where appropriate.
|
||||
"""
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from service.account_crypto_commands import (
|
||||
cmd_account_export_credentials,
|
||||
cmd_account_import_credentials,
|
||||
cmd_account_init_master_key,
|
||||
)
|
||||
from service.account_service import (
|
||||
cmd_add,
|
||||
cmd_account_add_secret,
|
||||
cmd_account_add_web,
|
||||
cmd_account_get,
|
||||
cmd_account_get_credential,
|
||||
cmd_account_list,
|
||||
cmd_account_mark_error,
|
||||
cmd_account_mark_session,
|
||||
cmd_account_mark_used,
|
||||
cmd_account_pick_web,
|
||||
cmd_credential_pick,
|
||||
cmd_credential_resolve,
|
||||
cmd_delete_by_id,
|
||||
cmd_delete_by_platform,
|
||||
cmd_delete_by_platform_phone,
|
||||
cmd_get,
|
||||
cmd_lease_cleanup,
|
||||
cmd_lease_list,
|
||||
cmd_lease_release,
|
||||
cmd_list,
|
||||
cmd_list_json,
|
||||
cmd_pick_logged_in,
|
||||
cmd_pick_web,
|
||||
cmd_set_login_status,
|
||||
cmd_platform_ensure,
|
||||
cmd_platform_get,
|
||||
cmd_platform_list,
|
||||
)
|
||||
from service.browser_service import cmd_login, cmd_open
|
||||
from util.constants import _apply_cli_local_dev_env
|
||||
from util.logging_config import get_skill_logger
|
||||
from service.browser_service import cmd_open
|
||||
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
|
||||
from util.logging_config import get_skill_logger, setup_skill_logging
|
||||
from util.platforms import _platform_list_cn_for_help, resolve_platform_key
|
||||
from util.runtime_paths import _maybe_print_paths_debug_cli
|
||||
|
||||
# =========================================================================
|
||||
# Help text
|
||||
# =========================================================================
|
||||
|
||||
def _cli_print_full_usage() -> None:
|
||||
"""总览(未带子命令或需要完整说明时)。"""
|
||||
print("用法概览(将 main.py 换为你的路径):")
|
||||
print(" python main.py add <平台中文名> <手机号>")
|
||||
print(" python main.py list [平台|all|全部]")
|
||||
print(" python main.py pick-logged-in <平台> # 跨技能用:仅已登录账号,一行 JSON 或 ERROR")
|
||||
print(" python main.py pick-web <平台> # 跨技能用:优先已登录,否则最新一条账号")
|
||||
print(" python main.py list-json <平台|all> [--limit N] # 跨技能用:一行 JSON 数组,元素同 get")
|
||||
print(" python main.py get <id>")
|
||||
print(" python main.py set-login-status <id> <0|1> # 跨技能回写登录态")
|
||||
print(" python main.py open <id>")
|
||||
print(" python main.py login <id>")
|
||||
print(" python main.py delete id <id>")
|
||||
print(" python main.py delete platform <平台>")
|
||||
print(" python main.py delete platform <平台> <手机号>")
|
||||
print(" python main.py <平台> # 等价于只列该平台")
|
||||
print(" python main.py <平台> list # 同上")
|
||||
print()
|
||||
print("支持的平台(可用下列中文称呼,亦支持英文键如 sohu):")
|
||||
print(" " + _platform_list_cn_for_help())
|
||||
_USAGE = """account-manager v2 — Credential & Browser Profile Manager
|
||||
|
||||
平台管理:
|
||||
platform list [--domain logistics|llm|content]
|
||||
platform get <platform>
|
||||
platform ensure --key <key> --display-name <name> [--domain <d>]
|
||||
[--url <url>] [--auth-strategy <s>]
|
||||
|
||||
账号管理:
|
||||
account add-web --platform <p> --login-id <id> --login-id-type <type>
|
||||
[--label <label>] [--tenant-id <t>] [--environment <env>]
|
||||
[--role <role>] [--provider-code <code>] [--url <url>]
|
||||
[--extra-json <json>] [--profile-dir <path>]
|
||||
[--auth-strategy <s>] [--session-persistent 0|1] [--device-fingerprint <fp>]
|
||||
account add-secret --platform <p> --credential-type <type>
|
||||
--secret-storage env|windows_credential|none|local_encrypted
|
||||
[--secret-ref <env_var>] [--secret-stdin]
|
||||
[--label <label>] [--account-id <id>]
|
||||
[--environment <env>] [--role <role>]
|
||||
account get <id> [--with-credentials]
|
||||
account get-credential <account_id> [--reveal --lease-token <token>]
|
||||
account list [--platform <p>] [--environment <env>]
|
||||
[--tenant-id <t>] [--role <role>] [--limit N]
|
||||
account pick-web --platform <p> [--environment <env>] [--tenant-id <t>]
|
||||
[--role <role>] [--lease] [--ttl-sec 900]
|
||||
[--holder <holder>] [--purpose <purpose>]
|
||||
account mark-session <id> --session-status <status>
|
||||
account mark-error <id> --code <code> --message <msg>
|
||||
account mark-used <id>
|
||||
account open <id>
|
||||
account init-master-key [--print|--from-env|--rotate]
|
||||
account export-credentials --plaintext
|
||||
account import-credentials [--replace-all] < backup.json
|
||||
|
||||
凭据管理:
|
||||
credential pick --platform <p> [--type <type>] [--environment <env>]
|
||||
[--role <role>] [--reveal]
|
||||
credential resolve --id <credential_id> [--reveal]
|
||||
|
||||
租约管理:
|
||||
lease release <lease_token>
|
||||
lease list [--active]
|
||||
lease cleanup
|
||||
|
||||
浏览器:
|
||||
account open <id>
|
||||
open <id> # 兼容别名
|
||||
|
||||
兼容入口(旧版):
|
||||
get <id>
|
||||
list-json <platform|all> [--limit N]
|
||||
pick-web <platform>
|
||||
list [platform|all]
|
||||
add <platform> <phone>
|
||||
delete id|platform <...>
|
||||
"""
|
||||
|
||||
|
||||
def _cli_fail_add() -> None:
|
||||
print("ERROR:CLI_ADD_MISSING_ARGS")
|
||||
print()
|
||||
print("【add 添加账号】参数不足。")
|
||||
print(" 必填:平台名称(中文即可,如 搜狐号、知乎、微信公众号)")
|
||||
print(" 必填:中国大陆 11 位手机号(1 开头,第二位 3~9),同平台下不可重复")
|
||||
print()
|
||||
print("示例:")
|
||||
print(" python main.py add 搜狐号 13800138000")
|
||||
print(" python main.py add 知乎 13900001111")
|
||||
print()
|
||||
print("支持的平台:" + _platform_list_cn_for_help())
|
||||
# =========================================================================
|
||||
# Argument parsing helpers
|
||||
# =========================================================================
|
||||
|
||||
def _get_opt(argv: list[str], flag: str, default=None):
|
||||
"""Get value of --flag from argv, returning default if not found."""
|
||||
if flag in argv:
|
||||
idx = argv.index(flag)
|
||||
if idx + 1 < len(argv):
|
||||
return argv[idx + 1]
|
||||
return default
|
||||
|
||||
|
||||
def _cli_fail_need_account_id(subcmd: str) -> None:
|
||||
print(f"ERROR:CLI_{subcmd.upper()}_MISSING_ID")
|
||||
print()
|
||||
print(f"【{subcmd}】缺少必填参数:账号 id(数据库自增整数,可先 list 查看)。")
|
||||
print("示例:")
|
||||
print(f" python main.py {subcmd} 1")
|
||||
def _has_flag(argv: list[str], flag: str) -> bool:
|
||||
return flag in argv
|
||||
|
||||
|
||||
def _cli_fail_delete() -> None:
|
||||
print("ERROR:CLI_DELETE_MISSING_ARGS")
|
||||
print()
|
||||
print("【delete 删除账号】参数不足。支持三种方式:")
|
||||
print(" 按 id:python main.py delete id <id>")
|
||||
print(" 按平台(删该平台下全部):python main.py delete platform <平台中文名或英文键>")
|
||||
print(" 按平台+手机号:python main.py delete platform <平台> <手机号>")
|
||||
print()
|
||||
print("示例:")
|
||||
print(" python main.py delete id 3")
|
||||
print(" python main.py delete platform 搜狐号")
|
||||
print(" python main.py delete platform 头条号 18925203701")
|
||||
print()
|
||||
print("说明:会同时删除数据库记录与 profile 用户数据目录(若存在)。")
|
||||
def _get_int_opt(argv: list[str], flag: str, default: int) -> int:
|
||||
v = _get_opt(argv, flag)
|
||||
if v is not None:
|
||||
try:
|
||||
return int(v)
|
||||
except ValueError:
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def _get_all_opts(argv: list[str], flag: str) -> list[str]:
|
||||
"""Collect all values for a repeatable flag (e.g. --platform-alias)."""
|
||||
vals: list[str] = []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
if argv[i] == flag and i + 1 < len(argv):
|
||||
vals.append(argv[i + 1])
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
return vals
|
||||
|
||||
|
||||
def _parse_platform_aliases(argv: list[str]) -> list[str]:
|
||||
aliases: list[str] = []
|
||||
for raw in _get_all_opts(argv, "--platform-alias"):
|
||||
for part in (raw or "").split(","):
|
||||
s = part.strip()
|
||||
if s and s not in aliases:
|
||||
aliases.append(s)
|
||||
return aliases
|
||||
|
||||
|
||||
def _parse_platform_caller_meta(argv: list[str]):
|
||||
from service.account_service import PlatformCallerMeta
|
||||
|
||||
display_name = _get_opt(argv, "--platform-display-name")
|
||||
url = _get_opt(argv, "--platform-url")
|
||||
domain = _get_opt(argv, "--platform-domain")
|
||||
auth_strategy = _get_opt(argv, "--platform-auth-strategy")
|
||||
aliases = _parse_platform_aliases(argv)
|
||||
if not any([display_name, url, domain, auth_strategy, aliases]):
|
||||
return None
|
||||
return PlatformCallerMeta(
|
||||
display_name=display_name,
|
||||
url=url,
|
||||
domain=domain,
|
||||
auth_strategy=auth_strategy,
|
||||
aliases=aliases,
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Main dispatch
|
||||
# =========================================================================
|
||||
|
||||
def main() -> None:
|
||||
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
|
||||
log = get_skill_logger()
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
_cli_print_full_usage()
|
||||
print(_USAGE)
|
||||
sys.exit(1)
|
||||
|
||||
_apply_cli_local_dev_env()
|
||||
get_skill_logger().info("cli_start argv=%s", sys.argv)
|
||||
cmd = sys.argv[1]
|
||||
log.info("cli_start argv=%s", sys.argv)
|
||||
av = sys.argv
|
||||
cmd = av[1]
|
||||
_maybe_print_paths_debug_cli()
|
||||
|
||||
# Determine if first arg is a platform key (for legacy shorthand)
|
||||
plat_from_first = resolve_platform_key(cmd)
|
||||
|
||||
if len(sys.argv) >= 3 and sys.argv[2] == "list" and plat_from_first:
|
||||
cmd_list(plat_from_first)
|
||||
elif plat_from_first and len(sys.argv) == 2:
|
||||
cmd_list(plat_from_first)
|
||||
elif cmd == "add":
|
||||
if len(sys.argv) < 4:
|
||||
_cli_fail_add()
|
||||
# ---- platform subcommand ----
|
||||
if cmd == "platform":
|
||||
if len(av) < 3:
|
||||
print("ERROR:CLI_BAD_ARGS platform 需要子命令:list | get | ensure")
|
||||
sys.exit(1)
|
||||
cmd_add(sys.argv[2], sys.argv[3])
|
||||
elif cmd == "list":
|
||||
cmd_list(sys.argv[2] if len(sys.argv) >= 3 else "all")
|
||||
sub = av[2]
|
||||
if sub == "list":
|
||||
domain = _get_opt(av, "--domain")
|
||||
cmd_platform_list(domain)
|
||||
elif sub == "get":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS platform get 需要 <platform>。")
|
||||
sys.exit(1)
|
||||
cmd_platform_get(av[3])
|
||||
elif sub == "ensure":
|
||||
import argparse as _ap
|
||||
p = _ap.ArgumentParser(prog="platform ensure")
|
||||
p.add_argument("--key", required=True)
|
||||
p.add_argument("--display-name", default="")
|
||||
p.add_argument("--domain", default="generic")
|
||||
p.add_argument("--url", default="")
|
||||
p.add_argument("--auth-strategy", default=None)
|
||||
p.add_argument("--aliases", default=None,
|
||||
help="JSON 数组字符串,如 '[\"1688\",\"阿里巴巴1688\"]'")
|
||||
p.add_argument("--capabilities", default=None,
|
||||
help='JSON 对象,如 \'{"web":true,"api_key":false,"rpa":true}\'')
|
||||
args = p.parse_args(av[3:])
|
||||
sys.exit(cmd_platform_ensure(
|
||||
key=args.key,
|
||||
display_name=args.display_name or args.key,
|
||||
domain=args.domain,
|
||||
url=args.url,
|
||||
auth_strategy=args.auth_strategy,
|
||||
aliases=args.aliases,
|
||||
capabilities=args.capabilities,
|
||||
))
|
||||
else:
|
||||
print("ERROR:CLI_BAD_ARGS platform 子命令必须是 list、get 或 ensure。")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- account subcommand ----
|
||||
elif cmd == "account":
|
||||
if len(av) < 3:
|
||||
print("ERROR:CLI_BAD_ARGS account 需要子命令(add-web / get / …)。")
|
||||
print(_USAGE)
|
||||
sys.exit(1)
|
||||
sub = av[2]
|
||||
if sub == "add-web":
|
||||
_cmd_add_web(av)
|
||||
elif sub == "add-secret":
|
||||
_cmd_add_secret(av)
|
||||
elif sub == "get-credential":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account get-credential 需要 <account_id>。")
|
||||
sys.exit(1)
|
||||
_cmd_get_credential(av)
|
||||
elif sub == "get":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account get 需要 <id>。")
|
||||
sys.exit(1)
|
||||
aid = av[3]
|
||||
with_creds = _has_flag(av, "--with-credentials")
|
||||
cmd_account_get(aid, include_credentials=with_creds)
|
||||
elif sub == "list":
|
||||
_cmd_account_list(av)
|
||||
elif sub == "pick-web":
|
||||
_cmd_pick_web_new(av)
|
||||
elif sub == "mark-session":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account mark-session 需要 <id>。")
|
||||
sys.exit(1)
|
||||
aid = av[3]
|
||||
ss = _get_opt(av, "--session-status", "unknown")
|
||||
cmd_account_mark_session(aid, ss)
|
||||
elif sub == "mark-error":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account mark-error 需要 <id>。")
|
||||
sys.exit(1)
|
||||
aid = av[3]
|
||||
ec = _get_opt(av, "--code", "")
|
||||
em = _get_opt(av, "--message", "")
|
||||
cmd_account_mark_error(aid, ec, em)
|
||||
elif sub == "mark-used":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account mark-used 需要 <id>。")
|
||||
sys.exit(1)
|
||||
aid = av[3]
|
||||
cmd_account_mark_used(aid)
|
||||
elif sub == "open":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account open 需要 <id>。")
|
||||
sys.exit(1)
|
||||
cmd_open(av[3])
|
||||
elif sub == "init-master-key":
|
||||
cmd_account_init_master_key(av)
|
||||
elif sub == "export-credentials":
|
||||
cmd_account_export_credentials(av)
|
||||
elif sub == "import-credentials":
|
||||
cmd_account_import_credentials(av)
|
||||
else:
|
||||
print("ERROR:CLI_BAD_ARGS 未知的 account 子命令。")
|
||||
print(_USAGE)
|
||||
sys.exit(1)
|
||||
|
||||
# ---- credential subcommand ----
|
||||
elif cmd == "credential":
|
||||
if len(av) < 3:
|
||||
print("ERROR:CLI_BAD_ARGS credential 需要子命令:pick | resolve")
|
||||
sys.exit(1)
|
||||
sub = av[2]
|
||||
if sub == "pick":
|
||||
_cmd_cred_pick(av)
|
||||
elif sub == "resolve":
|
||||
_cmd_cred_resolve(av)
|
||||
else:
|
||||
print("ERROR:CLI_BAD_ARGS credential 子命令必须是 pick 或 resolve。")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- lease subcommand ----
|
||||
elif cmd == "lease":
|
||||
if len(av) < 3:
|
||||
print("ERROR:CLI_BAD_ARGS lease 需要子命令:release | list | cleanup")
|
||||
sys.exit(1)
|
||||
sub = av[2]
|
||||
if sub == "release":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS lease release 需要 <lease_token>。")
|
||||
sys.exit(1)
|
||||
cmd_lease_release(av[3])
|
||||
elif sub == "list":
|
||||
active = _has_flag(av, "--active")
|
||||
cmd_lease_list(active)
|
||||
elif sub == "cleanup":
|
||||
cmd_lease_cleanup()
|
||||
else:
|
||||
print("ERROR:CLI_BAD_ARGS lease 子命令必须是 release、list 或 cleanup。")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- Legacy aliases ----
|
||||
elif cmd == "get":
|
||||
if len(sys.argv) < 3:
|
||||
_cli_fail_need_account_id("get")
|
||||
print("ERROR:CLI_GET_MISSING_ID")
|
||||
sys.exit(1)
|
||||
cmd_get(sys.argv[2])
|
||||
elif cmd == "set-login-status":
|
||||
if len(sys.argv) < 4:
|
||||
print("ERROR:CLI_SET_LOGIN_STATUS_MISSING_ARGS")
|
||||
print("用法:python main.py set-login-status <account_id> <0|1>")
|
||||
print("说明:供 llm-manager 等脚本回写登录态;成功时 stdout 首行以 OK: 开头。")
|
||||
sys.exit(1)
|
||||
cmd_set_login_status(sys.argv[2], sys.argv[3])
|
||||
|
||||
elif cmd == "list-json":
|
||||
if len(sys.argv) < 3:
|
||||
print("ERROR:CLI_LIST_JSON_MISSING_PLATFORM")
|
||||
print("用法:python main.py list-json <平台|all|全部> [--limit N]")
|
||||
print("说明:跨技能编排;成功时 stdout 仅一行 JSON 数组(元素与 get 一致)。")
|
||||
sys.exit(1)
|
||||
av = sys.argv[2:]
|
||||
platform_arg = av[0]
|
||||
@@ -132,33 +363,45 @@ def main() -> None:
|
||||
except ValueError:
|
||||
pass
|
||||
cmd_list_json(platform_arg, limit=lim)
|
||||
elif cmd == "pick-logged-in":
|
||||
if len(sys.argv) < 3:
|
||||
print("ERROR:CLI_PICK_LOGGED_IN_MISSING_ARGS")
|
||||
print("用法:python main.py pick-logged-in <平台中文名或英文键>")
|
||||
print("说明:供 llm-manager 等技能查询该平台已登录账号;成功时 stdout 仅一行 JSON。")
|
||||
sys.exit(1)
|
||||
cmd_pick_logged_in(sys.argv[2])
|
||||
|
||||
elif cmd == "pick-web":
|
||||
if len(sys.argv) < 3:
|
||||
print("ERROR:CLI_PICK_WEB_MISSING_ARGS")
|
||||
print("用法:python main.py pick-web <平台中文名或英文键>")
|
||||
print("说明:供 llm-manager 等;优先已登录账号,否则返回该平台最新一条账号(未登录也可用于打开浏览器登录)。")
|
||||
sys.exit(1)
|
||||
cmd_pick_web(sys.argv[2])
|
||||
|
||||
elif cmd == "open":
|
||||
if len(sys.argv) < 3:
|
||||
_cli_fail_need_account_id("open")
|
||||
print("ERROR:CLI_OPEN_MISSING_ID")
|
||||
sys.exit(1)
|
||||
cmd_open(sys.argv[2])
|
||||
elif cmd == "login":
|
||||
if len(sys.argv) < 3:
|
||||
_cli_fail_need_account_id("login")
|
||||
|
||||
elif cmd == "list":
|
||||
platform_arg = sys.argv[2] if len(sys.argv) >= 3 else "all"
|
||||
cmd_list(platform_arg)
|
||||
|
||||
elif cmd == "add":
|
||||
if len(sys.argv) < 4:
|
||||
print("ERROR:CLI_ADD_MISSING_ARGS")
|
||||
sys.exit(1)
|
||||
cmd_login(sys.argv[2])
|
||||
# Legacy add maps to add-web for backward compat
|
||||
cmd_account_add_web(
|
||||
platform_input=sys.argv[2],
|
||||
login_id=sys.argv[3],
|
||||
login_id_type="phone",
|
||||
label=None,
|
||||
tenant_id=None,
|
||||
environment="production",
|
||||
role="default",
|
||||
provider_code=None,
|
||||
url=None,
|
||||
extra_json_str=None,
|
||||
profile_dir_override=None,
|
||||
)
|
||||
|
||||
elif cmd == "delete":
|
||||
if len(sys.argv) < 4:
|
||||
_cli_fail_delete()
|
||||
print("ERROR:CLI_DELETE_MISSING_ARGS")
|
||||
sys.exit(1)
|
||||
mode = (sys.argv[2] or "").strip().lower()
|
||||
if mode == "id":
|
||||
@@ -169,17 +412,181 @@ def main() -> None:
|
||||
else:
|
||||
cmd_delete_by_platform(sys.argv[3])
|
||||
else:
|
||||
print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]}」,请使用 id 或 platform。")
|
||||
_cli_fail_delete()
|
||||
print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]}」。")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- Platform shorthand: "python main.py <platform>" ----
|
||||
elif plat_from_first:
|
||||
cmd_list(sys.argv[1])
|
||||
|
||||
# ---- Unknown ----
|
||||
else:
|
||||
print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别,或参数组合不合法。")
|
||||
print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别。")
|
||||
print()
|
||||
_cli_print_full_usage()
|
||||
print()
|
||||
print("说明:list 的筛选可为 all/全部,或上面列出的任一平台中文名。")
|
||||
print(_USAGE)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Sub-parsers for complex commands
|
||||
# =========================================================================
|
||||
|
||||
def _cmd_add_web(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform", "")
|
||||
login_id = _get_opt(argv, "--login-id")
|
||||
login_id_type = _get_opt(argv, "--login-id-type", "unknown")
|
||||
label = _get_opt(argv, "--label")
|
||||
tenant_id = _get_opt(argv, "--tenant-id")
|
||||
environment = _get_opt(argv, "--environment", "production")
|
||||
role = _get_opt(argv, "--role", "default")
|
||||
provider_code = _get_opt(argv, "--provider-code")
|
||||
url = _get_opt(argv, "--url")
|
||||
extra_json_str = _get_opt(argv, "--extra-json")
|
||||
profile_dir_override = _get_opt(argv, "--profile-dir")
|
||||
auth_strategy = _get_opt(argv, "--auth-strategy")
|
||||
session_persistent_raw = _get_opt(argv, "--session-persistent")
|
||||
device_fingerprint = _get_opt(argv, "--device-fingerprint")
|
||||
|
||||
session_persistent: Optional[int] = None
|
||||
if session_persistent_raw is not None:
|
||||
try:
|
||||
session_persistent = int(session_persistent_raw)
|
||||
except ValueError:
|
||||
print("ERROR:CLI_BAD_ARGS --session-persistent 必须是整数(0 或 1)。")
|
||||
return
|
||||
|
||||
if not plat:
|
||||
print("ERROR:CLI_BAD_ARGS account add-web 需要 --platform <platform>。")
|
||||
return
|
||||
platform_meta = _parse_platform_caller_meta(argv)
|
||||
cmd_account_add_web(
|
||||
platform_input=plat,
|
||||
login_id=login_id,
|
||||
login_id_type=login_id_type,
|
||||
label=label,
|
||||
tenant_id=tenant_id,
|
||||
environment=environment,
|
||||
role=role,
|
||||
provider_code=provider_code,
|
||||
url=url,
|
||||
extra_json_str=extra_json_str,
|
||||
profile_dir_override=profile_dir_override,
|
||||
auth_strategy=auth_strategy,
|
||||
session_persistent=session_persistent,
|
||||
device_fingerprint=device_fingerprint,
|
||||
platform_meta=platform_meta,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_get_credential(argv: list[str]) -> None:
|
||||
aid_raw = argv[3]
|
||||
try:
|
||||
aid = int(aid_raw)
|
||||
except ValueError:
|
||||
print(f"ERROR:CLI_BAD_ARGS account_id 必须是整数:{aid_raw}")
|
||||
return
|
||||
reveal = _has_flag(argv, "--reveal")
|
||||
lease_token = _get_opt(argv, "--lease-token")
|
||||
cmd_account_get_credential(aid, reveal=reveal, lease_token=lease_token)
|
||||
|
||||
|
||||
def _cmd_add_secret(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform", "")
|
||||
ctype = _get_opt(argv, "--credential-type", "api_key")
|
||||
label = _get_opt(argv, "--label")
|
||||
storage = _get_opt(argv, "--secret-storage", "env")
|
||||
stdin_mode = _has_flag(argv, "--secret-stdin")
|
||||
sref = _get_opt(argv, "--secret-ref")
|
||||
aid = _get_int_opt(argv, "--account-id", 0) or None
|
||||
env = _get_opt(argv, "--environment", "production")
|
||||
role = _get_opt(argv, "--role", "api")
|
||||
extra_json_str = _get_opt(argv, "--extra-json")
|
||||
|
||||
if not plat:
|
||||
print("ERROR:CLI_BAD_ARGS account add-secret 需要 --platform <platform>。")
|
||||
return
|
||||
cmd_account_add_secret(
|
||||
platform_input=plat,
|
||||
credential_type=ctype,
|
||||
label=label,
|
||||
secret_storage=storage,
|
||||
secret_stdin=stdin_mode,
|
||||
secret_ref=sref,
|
||||
account_id=aid,
|
||||
environment=env,
|
||||
role=role,
|
||||
extra_json_str=extra_json_str,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_account_list(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform")
|
||||
env = _get_opt(argv, "--environment")
|
||||
tenant = _get_opt(argv, "--tenant-id")
|
||||
role = _get_opt(argv, "--role")
|
||||
limit = _get_int_opt(argv, "--limit", 200)
|
||||
cmd_account_list(
|
||||
platform_input=plat,
|
||||
environment=env,
|
||||
tenant_id=tenant,
|
||||
role=role,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_pick_web_new(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform", "")
|
||||
env = _get_opt(argv, "--environment")
|
||||
tenant = _get_opt(argv, "--tenant-id")
|
||||
role = _get_opt(argv, "--role")
|
||||
do_lease = _has_flag(argv, "--lease")
|
||||
ttl = _get_int_opt(argv, "--ttl-sec", 900)
|
||||
holder = _get_opt(argv, "--holder")
|
||||
purpose = _get_opt(argv, "--purpose")
|
||||
|
||||
if not plat:
|
||||
print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform <platform>。")
|
||||
return
|
||||
platform_meta = _parse_platform_caller_meta(argv)
|
||||
cmd_account_pick_web(
|
||||
platform_input=plat,
|
||||
environment=env,
|
||||
tenant_id=tenant,
|
||||
role=role,
|
||||
do_lease=do_lease,
|
||||
ttl_sec=ttl,
|
||||
holder=holder,
|
||||
purpose=purpose,
|
||||
platform_meta=platform_meta,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_cred_pick(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform", "")
|
||||
ctype = _get_opt(argv, "--type")
|
||||
env = _get_opt(argv, "--environment")
|
||||
role = _get_opt(argv, "--role")
|
||||
reveal = _has_flag(argv, "--reveal")
|
||||
if not plat:
|
||||
print("ERROR:CLI_BAD_ARGS credential pick 需要 --platform <platform>。")
|
||||
return
|
||||
cmd_credential_pick(
|
||||
platform_input=plat,
|
||||
credential_type=ctype,
|
||||
environment=env,
|
||||
role=role,
|
||||
reveal=reveal,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_cred_resolve(argv: list[str]) -> None:
|
||||
cid = _get_int_opt(argv, "--id", 0)
|
||||
reveal = _has_flag(argv, "--reveal")
|
||||
if cid <= 0:
|
||||
print("ERROR:CLI_BAD_ARGS credential resolve 需要 --id <credential_id>。")
|
||||
return
|
||||
cmd_credential_resolve(cid, reveal=reveal)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
29
scripts/db/auth_strategy.py
Normal file
29
scripts/db/auth_strategy.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Auth strategy identifiers for accounts (v2 schema).
|
||||
|
||||
Imported by migration, repositories, and later CLI/service layers.
|
||||
"""
|
||||
|
||||
AUTH_STRATEGY_PASSWORD_AUTO = "password_auto"
|
||||
AUTH_STRATEGY_PASSWORD_WITH_CAPTCHA = "password_with_captcha"
|
||||
AUTH_STRATEGY_PASSWORD_PLUS_2FA = "password_plus_2fa"
|
||||
AUTH_STRATEGY_QR_CODE_MANUAL = "qr_code_manual"
|
||||
AUTH_STRATEGY_PER_SESSION_MANUAL = "per_session_manual"
|
||||
AUTH_STRATEGY_DEVICE_BOUND = "device_bound"
|
||||
AUTH_STRATEGY_CLIENT_CERTIFICATE = "client_certificate"
|
||||
AUTH_STRATEGY_API_TOKEN = "api_token"
|
||||
AUTH_STRATEGY_SSO_REDIRECT = "sso_redirect"
|
||||
|
||||
ALL_AUTH_STRATEGIES = frozenset(
|
||||
{
|
||||
AUTH_STRATEGY_PASSWORD_AUTO,
|
||||
AUTH_STRATEGY_PASSWORD_WITH_CAPTCHA,
|
||||
AUTH_STRATEGY_PASSWORD_PLUS_2FA,
|
||||
AUTH_STRATEGY_QR_CODE_MANUAL,
|
||||
AUTH_STRATEGY_PER_SESSION_MANUAL,
|
||||
AUTH_STRATEGY_DEVICE_BOUND,
|
||||
AUTH_STRATEGY_CLIENT_CERTIFICATE,
|
||||
AUTH_STRATEGY_API_TOKEN,
|
||||
AUTH_STRATEGY_SSO_REDIRECT,
|
||||
}
|
||||
)
|
||||
@@ -1,21 +1,115 @@
|
||||
"""SQLite connection and schema bootstrap."""
|
||||
import sqlite3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
SQLite connection with schema migration (v1 -> v2).
|
||||
|
||||
from db.schema import ACCOUNTS_TABLE_SQL
|
||||
Migration strategy:
|
||||
- Check PRAGMA user_version.
|
||||
- If version < 2 and old 'accounts' table exists, back it up as
|
||||
'accounts_legacy_backup_<timestamp>' before creating new tables.
|
||||
- New tables are created with CREATE TABLE IF NOT EXISTS.
|
||||
- Logs all migration steps.
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from db.schema import SCHEMA_VERSION, ensure_schema
|
||||
from util.constants import SKILL_SLUG
|
||||
from util.logging_config import get_skill_logger
|
||||
from util.runtime_paths import get_db_path
|
||||
|
||||
|
||||
def get_conn():
|
||||
return sqlite3.connect(get_db_path())
|
||||
def get_conn() -> sqlite3.Connection:
|
||||
"""Open a connection to the skill database (autocommit off)."""
|
||||
conn = sqlite3.connect(get_db_path())
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=OFF")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
def _backup_old_accounts(conn: sqlite3.Connection) -> bool:
|
||||
"""Rename old 'accounts' table to 'accounts_legacy_backup_<ts>' if it exists with old schema.
|
||||
Returns True if backup was performed."""
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
|
||||
)
|
||||
if not cur.fetchone():
|
||||
return False
|
||||
|
||||
# Check if it's the old schema (has 'phone' column but no 'platform_key')
|
||||
# We just check the column count — old schema had fewer columns.
|
||||
cur.execute("PRAGMA table_info(accounts)")
|
||||
columns = [row[1] for row in cur.fetchall()]
|
||||
# Old schema: id, name, platform, phone, profile_dir, url, extra_json, created_at, updated_at = 9
|
||||
# New schema: many more. If it looks like old, back it up.
|
||||
has_legacy_structure = "phone" in columns and "platform_key" not in columns
|
||||
if not has_legacy_structure:
|
||||
return False
|
||||
|
||||
ts = int(time.time())
|
||||
backup_name = f"accounts_legacy_backup_{ts}"
|
||||
cur.execute(f"ALTER TABLE accounts RENAME TO {backup_name}")
|
||||
conn.commit()
|
||||
logger = get_skill_logger()
|
||||
logger.info(
|
||||
"schema_migration_backup old_table=accounts backup_table=%s",
|
||||
backup_name,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _check_schema_version(conn: sqlite3.Connection) -> int:
|
||||
cur = conn.cursor()
|
||||
cur.execute("PRAGMA user_version")
|
||||
row = cur.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Idempotent: migrate if needed, create tables, set schema version."""
|
||||
log = get_skill_logger()
|
||||
log.info("schema_init_start")
|
||||
conn = get_conn()
|
||||
try:
|
||||
old_version = _check_schema_version(conn)
|
||||
log.info("schema_current_version=%s", old_version)
|
||||
|
||||
if old_version < SCHEMA_VERSION:
|
||||
if old_version < 2:
|
||||
backed_up = _backup_old_accounts(conn)
|
||||
if backed_up:
|
||||
log.info("schema_init_backup_done old_version=%s", old_version)
|
||||
|
||||
ensure_schema(conn)
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||
conn.commit()
|
||||
|
||||
if old_version < SCHEMA_VERSION:
|
||||
log.info(
|
||||
"schema_init_done version=%s migrated_from=%s",
|
||||
SCHEMA_VERSION,
|
||||
old_version,
|
||||
)
|
||||
else:
|
||||
log.info("schema_init_uptodate version=%s", SCHEMA_VERSION)
|
||||
except Exception:
|
||||
log.exception("schema_init_failure")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_db_version() -> int:
|
||||
"""Return current PRAGMA user_version without initializing."""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'")
|
||||
if not cur.fetchone():
|
||||
cur.executescript(ACCOUNTS_TABLE_SQL)
|
||||
conn.commit()
|
||||
cur.execute("PRAGMA user_version")
|
||||
row = cur.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
206
scripts/db/credentials_repo.py
Normal file
206
scripts/db/credentials_repo.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Credentials repository.
|
||||
|
||||
包含加密路径(secret_storage='local_encrypted')与现存非加密路径
|
||||
(none/env/windows_credential)的统一访问。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from db.accounts_repo import _unix_to_iso_local, insert_credential as _insert_credential_raw
|
||||
from service.crypto import CredentialDecryptError, decrypt_secret
|
||||
from service.secret_store import delete_secret, mask_secret, read_secret
|
||||
|
||||
CREDENTIAL_TYPES = frozenset(
|
||||
{
|
||||
"password",
|
||||
"api_key",
|
||||
"api_token",
|
||||
"bearer_token",
|
||||
"oauth_client",
|
||||
"refresh_token",
|
||||
"browser_profile",
|
||||
"note",
|
||||
}
|
||||
)
|
||||
ALLOWED_SECRET_STORAGE = frozenset({"none", "env", "windows_credential", "local_encrypted"})
|
||||
|
||||
|
||||
class CredentialNotEncryptedError(Exception):
|
||||
"""Credential has no decryptable plaintext via this API (e.g. storage=none)."""
|
||||
|
||||
code = "ERR_CREDENTIAL_NOT_ENCRYPTED"
|
||||
|
||||
|
||||
def _validate_credential_type(credential_type: str) -> None:
|
||||
if credential_type not in CREDENTIAL_TYPES:
|
||||
raise ValueError(f"invalid credential_type: {credential_type!r}")
|
||||
|
||||
|
||||
def _validate_storage(secret_storage: str) -> None:
|
||||
if secret_storage not in ALLOWED_SECRET_STORAGE:
|
||||
raise ValueError(f"invalid secret_storage: {secret_storage!r}")
|
||||
|
||||
|
||||
def insert_credential_encrypted(
|
||||
conn,
|
||||
*,
|
||||
account_id: Optional[int],
|
||||
platform_key: str,
|
||||
credential_label: str,
|
||||
credential_type: str,
|
||||
plaintext: str,
|
||||
expires_at: Optional[int],
|
||||
extra_json: Optional[str],
|
||||
now: int,
|
||||
) -> int:
|
||||
_validate_credential_type(credential_type)
|
||||
if plaintext is None or str(plaintext).strip() == "":
|
||||
raise ValueError("plaintext required for encrypted credential insert")
|
||||
from service.crypto import encrypt_secret
|
||||
|
||||
ciphertext = encrypt_secret(str(plaintext))
|
||||
mask = mask_secret(str(plaintext))
|
||||
return _insert_credential_raw(
|
||||
conn,
|
||||
account_id,
|
||||
platform_key,
|
||||
credential_label,
|
||||
credential_type,
|
||||
"local_encrypted",
|
||||
None,
|
||||
mask,
|
||||
expires_at,
|
||||
extra_json,
|
||||
now,
|
||||
secret_ciphertext=ciphertext,
|
||||
)
|
||||
|
||||
|
||||
def insert_credential_external(
|
||||
conn,
|
||||
*,
|
||||
account_id: Optional[int],
|
||||
platform_key: str,
|
||||
credential_label: str,
|
||||
credential_type: str,
|
||||
secret_storage: str,
|
||||
secret_ref: Optional[str],
|
||||
secret_mask: Optional[str],
|
||||
expires_at: Optional[int],
|
||||
extra_json: Optional[str],
|
||||
now: int,
|
||||
) -> int:
|
||||
_validate_credential_type(credential_type)
|
||||
_validate_storage(secret_storage)
|
||||
if secret_storage == "local_encrypted":
|
||||
raise ValueError("use insert_credential_encrypted for local_encrypted")
|
||||
return _insert_credential_raw(
|
||||
conn,
|
||||
account_id,
|
||||
platform_key,
|
||||
credential_label,
|
||||
credential_type,
|
||||
secret_storage,
|
||||
secret_ref,
|
||||
secret_mask,
|
||||
expires_at,
|
||||
extra_json,
|
||||
now,
|
||||
secret_ciphertext=None,
|
||||
)
|
||||
|
||||
|
||||
def read_credential_plaintext(conn, credential_id: int) -> str:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT secret_storage, secret_ref, secret_ciphertext
|
||||
FROM credentials WHERE id = ?
|
||||
""",
|
||||
(int(credential_id),),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"credential not found: id={credential_id}")
|
||||
|
||||
storage = (row[0] or "").strip().lower()
|
||||
secret_ref = row[1] or ""
|
||||
secret_ciphertext = row[2]
|
||||
|
||||
if storage == "local_encrypted":
|
||||
if not secret_ciphertext:
|
||||
raise CredentialDecryptError("missing secret_ciphertext")
|
||||
return decrypt_secret(str(secret_ciphertext))
|
||||
|
||||
if storage == "none":
|
||||
raise CredentialNotEncryptedError("credential storage is none")
|
||||
|
||||
if storage == "env":
|
||||
return read_secret("env", secret_ref)
|
||||
|
||||
if storage == "windows_credential":
|
||||
return read_secret("windows_credential", secret_ref)
|
||||
|
||||
raise ValueError(f"unsupported secret_storage: {storage!r}")
|
||||
|
||||
|
||||
def list_credentials_by_account(conn, account_id: int) -> list[dict[str, Any]]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, account_id, platform_key, credential_label, credential_type,
|
||||
secret_storage, secret_ref, secret_mask, expires_at, status,
|
||||
last_used_at, extra_json, created_at, updated_at
|
||||
FROM credentials
|
||||
WHERE account_id = ?
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
""",
|
||||
(int(account_id),),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
ex = r[11]
|
||||
parsed_ex: Any
|
||||
try:
|
||||
parsed_ex = json.loads(ex) if isinstance(ex, str) else (ex or {})
|
||||
except json.JSONDecodeError:
|
||||
parsed_ex = {}
|
||||
out.append(
|
||||
{
|
||||
"id": r[0],
|
||||
"account_id": r[1],
|
||||
"platform_key": r[2],
|
||||
"credential_label": r[3],
|
||||
"credential_type": r[4],
|
||||
"secret_storage": r[5],
|
||||
"secret_ref": r[6] or "",
|
||||
"secret_mask": r[7] or "",
|
||||
"expires_at": _unix_to_iso_local(r[8]),
|
||||
"status": r[9],
|
||||
"last_used_at": _unix_to_iso_local(r[10]),
|
||||
"extra_json": parsed_ex,
|
||||
"created_at": _unix_to_iso_local(r[12]),
|
||||
"updated_at": _unix_to_iso_local(r[13]),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def delete_credential(conn, credential_id: int) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT secret_storage, secret_ref FROM credentials WHERE id = ?",
|
||||
(int(credential_id),),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return
|
||||
storage = (row[0] or "").strip().lower()
|
||||
secret_ref = row[1] or ""
|
||||
if storage == "windows_credential" and secret_ref:
|
||||
delete_secret("windows_credential", secret_ref)
|
||||
cur.execute("DELETE FROM credentials WHERE id = ?", (int(credential_id),))
|
||||
30
scripts/db/platform_defaults.py
Normal file
30
scripts/db/platform_defaults.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""平台默认 auth_strategy 映射表。
|
||||
|
||||
只覆盖已知平台;其它平台需要时手工 UPDATE platforms SET default_auth_strategy=... WHERE platform_key=...
|
||||
"""
|
||||
|
||||
PLATFORM_DEFAULT_AUTH_STRATEGY = {
|
||||
# 物流仿真平台(monitor-competitor-rates 在用)
|
||||
"maersk": "qr_code_manual",
|
||||
"cosco": "qr_code_manual",
|
||||
"msc": "qr_code_manual",
|
||||
# 内容平台(典型扫码)
|
||||
"douyin": "qr_code_manual",
|
||||
"wechat_mp": "qr_code_manual",
|
||||
"xiaohongshu": "qr_code_manual",
|
||||
"bilibili": "qr_code_manual",
|
||||
"toutiao": "qr_code_manual",
|
||||
"sohu": "qr_code_manual",
|
||||
# 开发平台(2FA 标配)
|
||||
"github": "password_plus_2fa",
|
||||
"aws": "password_plus_2fa",
|
||||
"gcp": "password_plus_2fa",
|
||||
# 内部演示/仿真(账密自动)
|
||||
"icbc_sim": "password_auto",
|
||||
# API 平台
|
||||
"openai": "api_token",
|
||||
"anthropic": "api_token",
|
||||
"deepseek": "api_token",
|
||||
"doubao": "api_token",
|
||||
}
|
||||
@@ -1,18 +1,292 @@
|
||||
# SQLite 无独立 DATETIME 类型: 时间统一存 INTEGER Unix 秒 (UTC), 查询/JSON 再转 ISO8601.
|
||||
# 下列 DDL 含注释, 会原样写入 sqlite_master, 便于 Navicat / DBeaver 等查看建表语句.
|
||||
ACCOUNTS_TABLE_SQL = """
|
||||
/* 多平台账号表: 与本地 Chromium/Edge 用户数据目录 (profile) 绑定, 供发布类技能读取登录态 */
|
||||
CREATE TABLE accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键 (自增)
|
||||
name TEXT NOT NULL, -- 展示名称, 如「搜狐1号」
|
||||
platform TEXT NOT NULL, -- 平台标识, 与内置 PLATFORMS 键一致, 如 sohu
|
||||
phone TEXT, -- 可选绑定手机号
|
||||
profile_dir TEXT, -- Playwright 用户数据目录 (绝对路径); 默认可读结构 profiles/<平台展示名>/<手机号>/
|
||||
url TEXT, -- 平台入口或登录页 URL
|
||||
login_status INTEGER NOT NULL DEFAULT 0, -- 是否已登录: 0 否 1 是 (由脚本校验后写入)
|
||||
last_login_at INTEGER, -- 最近一次登录成功时间, Unix 秒 UTC; 未登录过为 NULL
|
||||
extra_json TEXT, -- 扩展字段 JSON
|
||||
created_at INTEGER NOT NULL, -- 记录创建时间, Unix 秒 UTC
|
||||
updated_at INTEGER NOT NULL -- 记录最后更新时间, Unix 秒 UTC
|
||||
);
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Database schema v2 — Credential & Browser Profile Manager.
|
||||
|
||||
Time stored as INTEGER Unix seconds (UTC). DDL comments are preserved in
|
||||
sqlite_master, visible in DBeaver / Navicat.
|
||||
|
||||
Schema version management:
|
||||
- PRAGMA user_version = 2 (set in connection.py after ensure_schema).
|
||||
- _schema_meta schema_version: "2" after migrate_v1_to_v2, "2.1" after migrate_v2_to_v2_1.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
from db.auth_strategy import AUTH_STRATEGY_PASSWORD_AUTO
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# platforms — Registry of all platforms (logistics, LLM, content, etc.)
|
||||
# ---------------------------------------------------------------------------
|
||||
PLATFORMS_TABLE_SQL = textwrap.dedent("""\
|
||||
CREATE TABLE IF NOT EXISTS platforms (
|
||||
platform_key TEXT PRIMARY KEY, -- 平台唯一键,例如 maersk / deepseek / sohu
|
||||
display_name TEXT NOT NULL, -- 展示名称,例如 Maersk / DeepSeek / 搜狐号
|
||||
domain TEXT NOT NULL, -- 业务域:logistics / llm / content / ecommerce / generic
|
||||
provider_code TEXT, -- 供应商代码,物流 profile 可复用;例如 maersk、cma_cgm
|
||||
default_url TEXT, -- 默认入口 URL
|
||||
aliases_json TEXT NOT NULL DEFAULT '[]',-- 别名 JSON 数组,中英文别名
|
||||
capabilities_json TEXT NOT NULL DEFAULT '{}', -- 能力声明,例如 {"web":true,"api_key":true,"rpa":true}
|
||||
enabled INTEGER NOT NULL DEFAULT 1,-- 是否启用 (1=yes 0=no)
|
||||
default_auth_strategy TEXT, -- 新账号默认认证策略;可空,手工维护
|
||||
created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC
|
||||
updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC
|
||||
);
|
||||
""")
|
||||
|
||||
PLATFORMS_INDEXES_SQL = textwrap.dedent("""\
|
||||
CREATE INDEX IF NOT EXISTS idx_platforms_domain ON platforms(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_platforms_provider_code ON platforms(provider_code);
|
||||
""")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# accounts — An identity that can be used (web account, RPA profile, API account)
|
||||
# ---------------------------------------------------------------------------
|
||||
ACCOUNTS_TABLE_SQL = textwrap.dedent("""\
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键(自增)
|
||||
platform_key TEXT NOT NULL, -- 对应 platforms.platform_key
|
||||
account_label TEXT NOT NULL, -- 人可读名称,例如 Maersk simulator booking
|
||||
login_id TEXT, -- 登录标识:邮箱、用户名、手机号、客户代码等
|
||||
login_id_type TEXT NOT NULL DEFAULT 'unknown', -- email / username / phone / customer_code / api_account / unknown
|
||||
tenant_id TEXT, -- 租户/客户/项目标识,可空
|
||||
environment TEXT NOT NULL DEFAULT 'production',-- simulator / staging / production / test
|
||||
role TEXT NOT NULL DEFAULT 'default', -- booking / tracking / admin / sales / api / default
|
||||
provider_code TEXT, -- 冗余供应商码,便于业务 skill 查询
|
||||
profile_dir TEXT, -- Playwright 持久化浏览器用户数据目录;web/rpa 账号使用
|
||||
url TEXT, -- 账号默认入口 URL;为空时用 platforms.default_url
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active / disabled / needs_review
|
||||
session_status TEXT NOT NULL DEFAULT 'unknown', -- unknown / likely_valid / needs_login / expired
|
||||
last_used_at INTEGER, -- 最近被业务 skill pick/使用时间
|
||||
last_login_check_at INTEGER, -- 最近一次登录状态检查时间,可空
|
||||
last_error_code TEXT, -- 最近一次错误代码
|
||||
last_error_message TEXT, -- 最近一次错误消息
|
||||
extra_json TEXT NOT NULL DEFAULT '{}', -- 扩展字段 JSON
|
||||
auth_strategy TEXT NOT NULL, -- 认证策略(见 db.auth_strategy)
|
||||
session_persistent INTEGER NOT NULL DEFAULT 1, -- 是否需要 profile 持久化 (0/1)
|
||||
device_fingerprint TEXT, -- device_bound 策略用;可空
|
||||
created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC
|
||||
updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC
|
||||
);
|
||||
""")
|
||||
|
||||
ACCOUNTS_INDEXES_SQL = textwrap.dedent("""\
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_platform ON accounts(platform_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_pick_web ON accounts(platform_key, environment, tenant_id, role, status, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_provider ON accounts(provider_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status);
|
||||
""")
|
||||
|
||||
# Note: Unique constraint (platform_key, login_id, tenant_id, environment, role)
|
||||
# is intentionally NOT added here because SQLite allows multiple NULL login_id.
|
||||
# Application-level checks are used when login_id is provided.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# credentials — Secret-type credentials (API Key, Token, Password, etc.)
|
||||
# Raw secret is NEVER stored in SQLite. Only metadata, secret_ref, secret_mask.
|
||||
# ---------------------------------------------------------------------------
|
||||
CREDENTIALS_TABLE_SQL = textwrap.dedent("""\
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 凭据主键(自增)
|
||||
account_id INTEGER, -- 可空;关联 accounts.id;有些 API Key 可直接挂平台,也可挂具体账号
|
||||
platform_key TEXT NOT NULL, -- 平台唯一键
|
||||
credential_label TEXT NOT NULL, -- 人可读名称,例如 DeepSeek main API key
|
||||
credential_type TEXT NOT NULL, -- api_key / password / bearer_token / oauth_client / refresh_token / browser_profile / note
|
||||
secret_storage TEXT NOT NULL, -- none / env / windows_credential / local_encrypted
|
||||
secret_ref TEXT, -- env 时为环境变量名;windows_credential 时为 target name;none/local_encrypted 时为空
|
||||
secret_mask TEXT, -- 打码展示,例如 sk-****abcd;不能反推出原文
|
||||
secret_ciphertext TEXT, -- secret_storage='local_encrypted' 时为 Fernet 密文;其它后端为 NULL
|
||||
expires_at INTEGER, -- 过期时间,Unix 秒 UTC;可空
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active / disabled / expired / needs_rotation
|
||||
last_used_at INTEGER, -- 最近使用时间
|
||||
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
|
||||
|
||||
# 1–2: platforms.default_auth_strategy
|
||||
if not column_exists(conn, "platforms", "default_auth_strategy"):
|
||||
cur.execute("ALTER TABLE platforms ADD COLUMN default_auth_strategy TEXT")
|
||||
|
||||
for pk, strat in PLATFORM_DEFAULT_AUTH_STRATEGY.items():
|
||||
cur.execute(
|
||||
"UPDATE platforms SET default_auth_strategy = ? WHERE platform_key = ?",
|
||||
(strat, pk),
|
||||
)
|
||||
n_platform_updates += cur.rowcount
|
||||
|
||||
# 3: accounts columns (nullable auth_strategy during ALTER for legacy rows)
|
||||
if not column_exists(conn, "accounts", "auth_strategy"):
|
||||
cur.execute("ALTER TABLE accounts ADD COLUMN auth_strategy TEXT")
|
||||
if not column_exists(conn, "accounts", "session_persistent"):
|
||||
cur.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN session_persistent INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
if not column_exists(conn, "accounts", "device_fingerprint"):
|
||||
cur.execute("ALTER TABLE accounts ADD COLUMN device_fingerprint TEXT")
|
||||
|
||||
# 4: fill accounts.auth_strategy + session_persistent
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE accounts SET
|
||||
auth_strategy = COALESCE(
|
||||
(SELECT p.default_auth_strategy FROM platforms p
|
||||
WHERE p.platform_key = accounts.platform_key),
|
||||
?
|
||||
),
|
||||
session_persistent = COALESCE(session_persistent, 1)
|
||||
WHERE auth_strategy IS NULL OR TRIM(auth_strategy) = ''
|
||||
""",
|
||||
(AUTH_STRATEGY_PASSWORD_AUTO,),
|
||||
)
|
||||
m_accounts_updated = cur.rowcount
|
||||
|
||||
# 5: credentials.account_id (v1 may omit it)
|
||||
if not column_exists(conn, "credentials", "account_id"):
|
||||
cur.execute("ALTER TABLE credentials ADD COLUMN account_id INTEGER")
|
||||
|
||||
cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_credentials_account ON credentials(account_id)"
|
||||
)
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM platforms WHERE default_auth_strategy IS NULL")
|
||||
k_null_platforms = int(cur.fetchone()[0])
|
||||
|
||||
_schema_meta_upsert(conn, "schema_version", "2")
|
||||
conn.commit()
|
||||
|
||||
print(
|
||||
f"[migration] account-manager v1→v2: updated {n_platform_updates} platforms, "
|
||||
f"{m_accounts_updated} accounts.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[migration] platforms with default_auth_strategy=NULL: "
|
||||
f"{k_null_platforms} (manually fix later)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def migrate_v2_to_v2_1(conn: sqlite3.Connection) -> None:
|
||||
"""v2 → v2.1: credentials 表加 secret_ciphertext 列。幂等。"""
|
||||
cur = conn.cursor()
|
||||
if _schema_meta_get(conn, "schema_version") == "2.1":
|
||||
return
|
||||
|
||||
if column_exists(conn, "credentials", "secret_ciphertext"):
|
||||
_schema_meta_upsert(conn, "schema_version", "2.1")
|
||||
conn.commit()
|
||||
return
|
||||
|
||||
cur.execute("ALTER TABLE credentials ADD COLUMN secret_ciphertext TEXT")
|
||||
_schema_meta_upsert(conn, "schema_version", "2.1")
|
||||
conn.commit()
|
||||
print(
|
||||
"[migration] account-manager v2→v2.1: credentials.secret_ciphertext column added.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def ensure_schema(conn: sqlite3.Connection) -> None:
|
||||
"""CREATE TABLE/INDEX IF NOT EXISTS, then run migrate_v1_to_v2."""
|
||||
cur = conn.cursor()
|
||||
cur.executescript(ALL_DDL)
|
||||
conn.commit()
|
||||
migrate_v1_to_v2(conn)
|
||||
migrate_v2_to_v2_1(conn)
|
||||
|
||||
1
scripts/jiangchang_skill_core/__init__.py
Normal file
1
scripts/jiangchang_skill_core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Vendored from jiangchang-platform-kit/sdk/jiangchang_skill_core/ — keep runtime_env + unified_logging in sync.
|
||||
137
scripts/jiangchang_skill_core/runtime_env.py
Normal file
137
scripts/jiangchang_skill_core/runtime_env.py
Normal 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/Edge(launch 时 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()
|
||||
158
scripts/jiangchang_skill_core/unified_logging.py
Normal file
158
scripts/jiangchang_skill_core/unified_logging.py
Normal 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
|
||||
@@ -3,8 +3,17 @@ account-manager CLI 入口。
|
||||
|
||||
分层:cli(argv)→ service(用例)→ db(SQLite);共用工具在 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
|
||||
|
||||
255
scripts/service/_svc_common.py
Normal file
255
scripts/service/_svc_common.py
Normal 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
|
||||
545
scripts/service/account_add_commands.py
Normal file
545
scripts/service/account_add_commands.py
Normal 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": "",
|
||||
}))
|
||||
304
scripts/service/account_crypto_commands.py
Normal file
304
scripts/service/account_crypto_commands.py
Normal file
@@ -0,0 +1,304 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""CLI handlers for master key init and credential export/import (stage B)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from db.accounts_repo import _unix_to_iso_local
|
||||
from db.connection import get_conn, init_db
|
||||
from db.credentials_repo import insert_credential_encrypted
|
||||
from service.crypto import CredentialDecryptError, decrypt_secret
|
||||
from service.master_key import (
|
||||
ENV_MASTER_KEY,
|
||||
MasterKeyExistsError,
|
||||
MasterKeyInvalidError,
|
||||
generate_master_key,
|
||||
normalize_master_key_input,
|
||||
write_master_key_file,
|
||||
)
|
||||
from service.secret_store import mask_secret, read_secret
|
||||
|
||||
|
||||
def _has_flag(argv: list[str], flag: str) -> bool:
|
||||
return flag in argv
|
||||
|
||||
|
||||
def _get_opt(argv: list[str], flag: str, default=None):
|
||||
if flag in argv:
|
||||
idx = argv.index(flag)
|
||||
if idx + 1 < len(argv):
|
||||
return argv[idx + 1]
|
||||
return default
|
||||
|
||||
|
||||
def _key_preview(key_bytes: bytes) -> str:
|
||||
s = key_bytes.strip().decode("ascii")
|
||||
if len(s) <= 8:
|
||||
return "****"
|
||||
return s[:4] + "****" + s[-4:]
|
||||
|
||||
|
||||
def cmd_account_init_master_key(argv: list[str]) -> None:
|
||||
"""account init-master-key [--from-env] [--print] [--rotate]"""
|
||||
from_env = _has_flag(argv, "--from-env")
|
||||
do_print = _has_flag(argv, "--print")
|
||||
rotate = _has_flag(argv, "--rotate")
|
||||
|
||||
try:
|
||||
if from_env:
|
||||
raw = (os.environ.get(ENV_MASTER_KEY) or "").strip()
|
||||
if not raw:
|
||||
raise MasterKeyInvalidError("ACCOUNT_MANAGER_MASTER_KEY is not set")
|
||||
key = normalize_master_key_input(raw)
|
||||
else:
|
||||
key = generate_master_key()
|
||||
|
||||
if rotate:
|
||||
print(
|
||||
"[init-master-key] WARNING: --rotate overwrites master.key; "
|
||||
"ensure you have an export backup.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
path_written: Optional[str] = None
|
||||
if not do_print:
|
||||
path_written = write_master_key_file(key, allow_overwrite=rotate)
|
||||
|
||||
if do_print:
|
||||
line = json.dumps(
|
||||
{"success": True, "key": key.decode("ascii")},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
print(line)
|
||||
return
|
||||
|
||||
assert path_written is not None
|
||||
preview = _key_preview(key)
|
||||
print(
|
||||
json.dumps(
|
||||
{"success": True, "path": path_written, "key_preview": preview},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
except MasterKeyExistsError as e:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": {"code": e.code, "message": str(e)},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
except MasterKeyInvalidError as e:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": {"code": e.code, "message": str(e)},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def cmd_account_export_credentials(argv: list[str]) -> None:
|
||||
"""account export-credentials --plaintext"""
|
||||
if not _has_flag(argv, "--plaintext"):
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "ERR_EXPORT_REQUIRES_PLAINTEXT_FLAG",
|
||||
"message": "export requires explicit --plaintext acknowledgement",
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
print(
|
||||
"[export] WARNING: plaintext credentials written to stdout. Handle with extreme care.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, account_id, platform_key, credential_label, credential_type,
|
||||
secret_storage, secret_ref, secret_mask, secret_ciphertext,
|
||||
expires_at, extra_json, created_at, updated_at
|
||||
FROM credentials
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
cid = r[0]
|
||||
storage = (r[5] or "").strip().lower()
|
||||
secret_ref = r[6] or ""
|
||||
secret_mask = r[7] or ""
|
||||
ct = r[8]
|
||||
extra_raw = r[10]
|
||||
try:
|
||||
extra_obj = json.loads(extra_raw) if isinstance(extra_raw, str) else {}
|
||||
except json.JSONDecodeError:
|
||||
extra_obj = {}
|
||||
|
||||
plaintext: Optional[str] = None
|
||||
extra_out = dict(extra_obj)
|
||||
|
||||
if storage == "none":
|
||||
plaintext = None
|
||||
elif storage == "local_encrypted":
|
||||
try:
|
||||
if not ct:
|
||||
raise CredentialDecryptError("missing ciphertext")
|
||||
plaintext = decrypt_secret(str(ct))
|
||||
except CredentialDecryptError as e:
|
||||
print(
|
||||
f"[export] decrypt_failed id={cid} code={e.code} msg={e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
plaintext = None
|
||||
elif storage == "env":
|
||||
val = os.environ.get(secret_ref)
|
||||
if val is None:
|
||||
plaintext = None
|
||||
extra_out["export_note"] = "env var missing at export time"
|
||||
else:
|
||||
plaintext = val
|
||||
elif storage == "windows_credential":
|
||||
try:
|
||||
plaintext = read_secret("windows_credential", secret_ref)
|
||||
except (ValueError, OSError) as e:
|
||||
plaintext = None
|
||||
extra_out["export_note"] = f"windows_credential read failed: {e}"
|
||||
else:
|
||||
extra_out["export_note"] = f"unsupported storage at export: {storage}"
|
||||
|
||||
item = {
|
||||
"id": cid,
|
||||
"account_id": r[1],
|
||||
"platform_key": r[2],
|
||||
"credential_label": r[3],
|
||||
"credential_type": r[4],
|
||||
"secret_storage": r[5],
|
||||
"plaintext": plaintext,
|
||||
"secret_mask": secret_mask,
|
||||
"expires_at": _unix_to_iso_local(r[9]),
|
||||
"extra_json": extra_out,
|
||||
"created_at": _unix_to_iso_local(r[11]),
|
||||
"updated_at": _unix_to_iso_local(r[12]),
|
||||
}
|
||||
out.append(item)
|
||||
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _iso_maybe_to_unix(val: Any) -> Optional[int]:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, (int, float)):
|
||||
return int(val)
|
||||
s = str(val).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(s)
|
||||
return int(dt.timestamp())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def cmd_account_import_credentials(argv: list[str]) -> None:
|
||||
"""account import-credentials [--replace-all] < stdin JSON array"""
|
||||
replace_all = _has_flag(argv, "--replace-all")
|
||||
|
||||
raw = sys.stdin.read()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[import] fatal: invalid JSON stdin: {e}", file=sys.stderr)
|
||||
return
|
||||
|
||||
if not isinstance(data, list):
|
||||
print("[import] fatal: stdin JSON must be an array", file=sys.stderr)
|
||||
return
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
if replace_all:
|
||||
cur.execute(
|
||||
"DELETE FROM credentials WHERE secret_storage = ?",
|
||||
("local_encrypted",),
|
||||
)
|
||||
now = int(time.time())
|
||||
for idx, row in enumerate(data):
|
||||
if not isinstance(row, dict):
|
||||
print(f"[import] skip line {idx}: not an object", file=sys.stderr)
|
||||
skipped += 1
|
||||
continue
|
||||
pt = row.get("plaintext")
|
||||
if pt is None or str(pt).strip() == "":
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
plat = str(row.get("platform_key") or "").strip()
|
||||
label = str(row.get("credential_label") or "").strip()
|
||||
ctype = str(row.get("credential_type") or "").strip()
|
||||
if not plat or not label or not ctype:
|
||||
raise ValueError("missing platform_key/credential_label/credential_type")
|
||||
|
||||
account_id = row.get("account_id")
|
||||
aid = int(account_id) if account_id is not None else None
|
||||
|
||||
expires_at = _iso_maybe_to_unix(row.get("expires_at"))
|
||||
ex = row.get("extra_json")
|
||||
if isinstance(ex, dict):
|
||||
extra_json = json.dumps(ex, ensure_ascii=False)
|
||||
elif isinstance(ex, str):
|
||||
extra_json = ex
|
||||
else:
|
||||
extra_json = "{}"
|
||||
|
||||
insert_credential_encrypted(
|
||||
conn,
|
||||
account_id=aid,
|
||||
platform_key=plat,
|
||||
credential_label=label,
|
||||
credential_type=ctype,
|
||||
plaintext=str(pt),
|
||||
expires_at=expires_at,
|
||||
extra_json=extra_json,
|
||||
now=now,
|
||||
)
|
||||
inserted += 1
|
||||
except Exception as e:
|
||||
print(f"[import] skip line {idx}: {e}", file=sys.stderr)
|
||||
skipped += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
print(
|
||||
f"[import] inserted {inserted} credentials, skipped {skipped} lines (no plaintext).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
51
scripts/service/account_mark_commands.py
Normal file
51
scripts/service/account_mark_commands.py
Normal 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"}))
|
||||
325
scripts/service/account_query_commands.py
Normal file
325
scripts/service/account_query_commands.py
Normal 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)
|
||||
@@ -1,380 +1,68 @@
|
||||
"""账号用例:list / get / add / delete / pick / set-login-status(含终端输出,供 CLI 调用)。"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Account service layer — use cases invoked by CLI commands.
|
||||
|
||||
from db.accounts_repo import (
|
||||
count_platform_accounts_conn,
|
||||
default_name_for_platform,
|
||||
delete_account_by_id_conn,
|
||||
delete_account_platform_phone_conn,
|
||||
delete_accounts_by_platform_conn,
|
||||
fetch_delete_row_by_id_conn,
|
||||
fetch_ids_for_list_json,
|
||||
fetch_ids_profile_for_platform_conn,
|
||||
fetch_list_rows,
|
||||
fetch_pick_logged_in_id,
|
||||
fetch_pick_web_candidate_id,
|
||||
fetch_row_platform_phone_conn,
|
||||
get_account_by_id,
|
||||
has_duplicate_phone_conn,
|
||||
insert_account_row,
|
||||
mark_login_status,
|
||||
normalize_account_id,
|
||||
Each function handles:
|
||||
1. Input validation
|
||||
2. Logging key nodes
|
||||
3. Calling the repository layer
|
||||
4. Formatting JSON/stdout output
|
||||
|
||||
Raw secret is NEVER logged or printed in plaintext.
|
||||
|
||||
Implementation is split across service/*_commands.py modules to stay within
|
||||
PyArmor trial per-file line limits. This module re-exports the public API.
|
||||
"""
|
||||
from service._svc_common import PlatformCallerMeta
|
||||
from service.account_add_commands import cmd_account_add_secret, cmd_account_add_web
|
||||
from service.account_mark_commands import (
|
||||
cmd_account_mark_error,
|
||||
cmd_account_mark_session,
|
||||
cmd_account_mark_used,
|
||||
)
|
||||
from db.connection import get_conn, init_db
|
||||
from util.logging_config import get_skill_logger
|
||||
from util.platforms import (
|
||||
PLATFORM_URLS,
|
||||
_PLATFORM_PRIMARY_CN,
|
||||
_is_valid_cn_mobile11,
|
||||
_normalize_phone_digits,
|
||||
_platform_list_cn_for_help,
|
||||
resolve_platform_key,
|
||||
from service.account_query_commands import (
|
||||
cmd_account_get,
|
||||
cmd_account_get_credential,
|
||||
cmd_account_list,
|
||||
cmd_account_pick_web,
|
||||
cmd_get,
|
||||
cmd_list_json,
|
||||
cmd_pick_web,
|
||||
)
|
||||
from util.runtime_paths import _runtime_paths_debug_text, get_default_profile_dir
|
||||
from service.credential_commands import cmd_credential_pick, cmd_credential_resolve
|
||||
from service.legacy_commands import (
|
||||
cmd_delete_by_id,
|
||||
cmd_delete_by_platform,
|
||||
cmd_delete_by_platform_phone,
|
||||
cmd_list,
|
||||
)
|
||||
from service.lease_commands import cmd_lease_cleanup, cmd_lease_list, cmd_lease_release
|
||||
from service.platform_commands import cmd_platform_ensure, cmd_platform_get, cmd_platform_list
|
||||
|
||||
|
||||
def _remove_profile_dir(path: str) -> None:
|
||||
p = (path or "").strip()
|
||||
if not p or not os.path.isdir(p):
|
||||
return
|
||||
try:
|
||||
shutil.rmtree(p)
|
||||
except OSError as e:
|
||||
print(f"⚠️ 未能删除用户数据目录(可手工删):{p}\n {e}", file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_list(platform="all", limit: int = 10):
|
||||
get_skill_logger().info("list filter=%r", platform)
|
||||
init_db()
|
||||
raw = (platform or "all").strip()
|
||||
if not raw or raw.lower() == "all" or raw == "全部":
|
||||
key = "all"
|
||||
else:
|
||||
key = resolve_platform_key(raw)
|
||||
if not key:
|
||||
get_skill_logger().warning("list_invalid_platform raw=%r", raw)
|
||||
print(f"ERROR:INVALID_PLATFORM_LIST 无法识别的平台「{raw}」")
|
||||
print("支持:" + _platform_list_cn_for_help())
|
||||
return
|
||||
|
||||
if limit <= 0:
|
||||
limit = 10
|
||||
|
||||
rows = fetch_list_rows(key, int(limit))
|
||||
|
||||
found = bool(rows)
|
||||
if found:
|
||||
get_skill_logger().info("list_ok rows=%s key=%s", len(rows), key)
|
||||
sep_line = "_" * 39
|
||||
for idx, row in enumerate(rows):
|
||||
(
|
||||
aid,
|
||||
name,
|
||||
plat,
|
||||
phone,
|
||||
profile_dir,
|
||||
url,
|
||||
login_status,
|
||||
last_login_at,
|
||||
extra_json,
|
||||
created_at,
|
||||
updated_at,
|
||||
) = row
|
||||
print(f"id:{aid}")
|
||||
print(f"name:{name or ''}")
|
||||
print(f"platform:{plat or ''}")
|
||||
print(f"phone:{phone or ''}")
|
||||
print(f"profile_dir:{profile_dir or ''}")
|
||||
print(f"url:{url or ''}")
|
||||
print(f"login_status:{int(login_status) if login_status is not None else ''}")
|
||||
print(f"last_login_at:{int(last_login_at) if last_login_at is not None else ''}")
|
||||
print(f"extra_json:{extra_json or ''}")
|
||||
print(f"created_at:{int(created_at) if created_at is not None else ''}")
|
||||
print(f"updated_at:{int(updated_at) if updated_at is not None else ''}")
|
||||
if idx != len(rows) - 1:
|
||||
print(sep_line)
|
||||
print()
|
||||
|
||||
if not found:
|
||||
print("ERROR:NO_ACCOUNTS_FOUND")
|
||||
print(_runtime_paths_debug_text(), file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_get(account_id):
|
||||
get_skill_logger().info("get account_id=%r", account_id)
|
||||
acc = get_account_by_id(account_id)
|
||||
if acc:
|
||||
print(json.dumps(acc, ensure_ascii=False))
|
||||
return
|
||||
get_skill_logger().warning("get_not_found account_id=%r", account_id)
|
||||
print("ERROR:ACCOUNT_NOT_FOUND")
|
||||
print(_runtime_paths_debug_text(), file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_list_json(platform_input: str, limit: int = 200) -> None:
|
||||
"""
|
||||
跨技能接口:stdout 仅输出一行 JSON 数组,元素结构与 get 单条一致。
|
||||
平台可为 all/全部 或各平台中文名/英文键;按 updated_at 倒序,最多 limit 条(上限 500)。
|
||||
"""
|
||||
get_skill_logger().info("list_json filter=%r limit=%s", platform_input, limit)
|
||||
init_db()
|
||||
raw = (platform_input or "all").strip()
|
||||
if not raw or raw.lower() == "all" or raw == "全部":
|
||||
key = "all"
|
||||
else:
|
||||
key = resolve_platform_key(raw)
|
||||
if not key:
|
||||
print("ERROR:INVALID_PLATFORM_LIST_JSON 无法识别的平台名称。")
|
||||
print("支持:" + _platform_list_cn_for_help())
|
||||
return
|
||||
lim = max(1, min(int(limit), 500))
|
||||
ids = fetch_ids_for_list_json(key, lim)
|
||||
out = []
|
||||
for aid in ids:
|
||||
acc = get_account_by_id(aid)
|
||||
if acc:
|
||||
out.append(acc)
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
|
||||
|
||||
def cmd_pick_logged_in(platform_input: str):
|
||||
"""
|
||||
机器可读跨技能接口:查询指定平台下「已登录」的一条账号(login_status=1,按 last_login_at 优先)。
|
||||
成功:stdout 仅输出一行 JSON,结构与 get 子命令一致。
|
||||
失败:stdout 首行以 ERROR: 开头(由调用方判断,勿解析为 JSON)。
|
||||
"""
|
||||
get_skill_logger().info("pick_logged_in platform_input=%r", platform_input)
|
||||
key = resolve_platform_key((platform_input or "").strip())
|
||||
if not key:
|
||||
print("ERROR:INVALID_PLATFORM 无法识别的平台名称。")
|
||||
print("支持:" + _platform_list_cn_for_help())
|
||||
return
|
||||
|
||||
init_db()
|
||||
picked = fetch_pick_logged_in_id(key)
|
||||
|
||||
if picked is None:
|
||||
print("ERROR:NO_LOGGED_IN_ACCOUNT 该平台暂无已登录账号(login_status=1)。")
|
||||
print("请先 list 查看账号 id,再执行:python main.py login <id>")
|
||||
return
|
||||
|
||||
acc = get_account_by_id(picked)
|
||||
if not acc:
|
||||
print("ERROR:ACCOUNT_NOT_FOUND")
|
||||
print(_runtime_paths_debug_text(), file=sys.stderr)
|
||||
return
|
||||
print(json.dumps(acc, ensure_ascii=False))
|
||||
|
||||
|
||||
def cmd_pick_web(platform_input: str):
|
||||
"""
|
||||
供 llm-manager 等:取该平台用于网页自动化的账号候选。
|
||||
优先 login_status=1(与 pick-logged-in 一致);若无,则取该平台 updated_at 最新的一条,
|
||||
便于「已 add 未标登录」时仍打开 profile,在浏览器内登录后继续任务。
|
||||
成功:stdout 仅一行 JSON(与 get 一致);失败:首行 ERROR:。
|
||||
"""
|
||||
get_skill_logger().info("pick_web platform_input=%r", platform_input)
|
||||
key = resolve_platform_key((platform_input or "").strip())
|
||||
if not key:
|
||||
print("ERROR:INVALID_PLATFORM 无法识别的平台名称。")
|
||||
print("支持:" + _platform_list_cn_for_help())
|
||||
return
|
||||
|
||||
init_db()
|
||||
picked = fetch_pick_web_candidate_id(key)
|
||||
|
||||
if picked is None:
|
||||
print("ERROR:NO_ACCOUNT 该平台在账号库中没有任何记录,请先执行 add。")
|
||||
return
|
||||
|
||||
acc = get_account_by_id(picked)
|
||||
if not acc:
|
||||
print("ERROR:ACCOUNT_NOT_FOUND")
|
||||
print(_runtime_paths_debug_text(), file=sys.stderr)
|
||||
return
|
||||
print(json.dumps(acc, ensure_ascii=False))
|
||||
|
||||
|
||||
def cmd_add(platform_input: str, phone: str):
|
||||
"""添加账号:platform 可为中文展示名或英文键;手机号必填;同平台下同手机号不可重复。"""
|
||||
log = get_skill_logger()
|
||||
log.info("add_attempt platform_input=%r", platform_input)
|
||||
init_db()
|
||||
key = resolve_platform_key((platform_input or "").strip())
|
||||
if not key:
|
||||
log.warning("add_invalid_platform input=%r", platform_input)
|
||||
print("ERROR:INVALID_PLATFORM 无法识别的平台名称。")
|
||||
print("支持:" + _platform_list_cn_for_help())
|
||||
return
|
||||
|
||||
phone_raw = (phone or "").strip()
|
||||
if not phone_raw:
|
||||
log.warning("add_phone_missing")
|
||||
print("ERROR:PHONE_REQUIRED 手机号为必填。")
|
||||
return
|
||||
phone_norm = _normalize_phone_digits(phone_raw)
|
||||
if not _is_valid_cn_mobile11(phone_norm):
|
||||
log.warning("add_phone_invalid digits_len=%s", len(phone_norm or ""))
|
||||
print(
|
||||
"ERROR:PHONE_INVALID 手机号格式不正确:须为中国大陆 11 位号码,"
|
||||
"以 1 开头、第二位为 3~9(示例 13800138000)。"
|
||||
)
|
||||
return
|
||||
|
||||
url = PLATFORM_URLS[key]
|
||||
now = int(time.time())
|
||||
phone_store = phone_norm
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
if has_duplicate_phone_conn(conn, key, phone_store):
|
||||
log.warning("add_duplicate platform=%s phone_suffix=%s", key, phone_store[-4:])
|
||||
print(
|
||||
f"ERROR:DUPLICATE_PHONE_PLATFORM 该平台下已存在手机号 {phone_store},请勿重复添加。"
|
||||
)
|
||||
return
|
||||
|
||||
next_idx = count_platform_accounts_conn(conn, key) + 1
|
||||
name = default_name_for_platform(key, next_idx)
|
||||
profile_dir = get_default_profile_dir(key, phone_store, None)
|
||||
new_id = insert_account_row(conn, name, key, phone_store, profile_dir, url, now)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
print(
|
||||
f"✅ 已保存账号:ID {new_id} | {name} | {_PLATFORM_PRIMARY_CN.get(key, key)} | 手机 {phone_store}"
|
||||
)
|
||||
print(f"ℹ️ 登录请执行:python main.py login {new_id}")
|
||||
log.info(
|
||||
"add_success id=%s platform=%s profile_dir=%s",
|
||||
new_id,
|
||||
key,
|
||||
profile_dir,
|
||||
)
|
||||
|
||||
|
||||
def cmd_delete_by_id(account_id) -> None:
|
||||
log = get_skill_logger()
|
||||
log.info("delete_by_id account_id=%r", account_id)
|
||||
init_db()
|
||||
aid = normalize_account_id(account_id)
|
||||
if aid is None or (isinstance(aid, str) and not str(aid).isdigit()):
|
||||
log.warning("delete_invalid_id")
|
||||
print("ERROR:DELETE_INVALID_ID 账号 id 须为正整数。")
|
||||
return
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = fetch_delete_row_by_id_conn(conn, int(aid))
|
||||
if not row:
|
||||
log.warning("delete_by_id_not_found id=%s", aid)
|
||||
print("ERROR:ACCOUNT_NOT_FOUND")
|
||||
print(_runtime_paths_debug_text(), file=sys.stderr)
|
||||
return
|
||||
rid, name, plat, phone, profile_dir = row
|
||||
delete_account_by_id_conn(conn, rid)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_remove_profile_dir(profile_dir)
|
||||
print(
|
||||
f"✅ 已删除账号:ID {rid} | {name} | {_PLATFORM_PRIMARY_CN.get(plat, plat)} | 手机 {phone or '(无)'}"
|
||||
)
|
||||
log.info("delete_by_id_done id=%s platform=%s", rid, plat)
|
||||
|
||||
|
||||
def cmd_delete_by_platform(platform_input: str) -> None:
|
||||
log = get_skill_logger()
|
||||
log.info("delete_by_platform platform_input=%r", platform_input)
|
||||
init_db()
|
||||
key = resolve_platform_key((platform_input or "").strip())
|
||||
if not key:
|
||||
log.warning("delete_by_platform_invalid_platform")
|
||||
print("ERROR:INVALID_PLATFORM 无法识别的平台名称。")
|
||||
print("支持:" + _platform_list_cn_for_help())
|
||||
return
|
||||
conn = get_conn()
|
||||
try:
|
||||
rows = fetch_ids_profile_for_platform_conn(conn, key)
|
||||
if not rows:
|
||||
log.warning("delete_by_platform_empty platform=%s", key)
|
||||
print("ERROR:NO_ACCOUNTS_FOUND 该平台下没有账号记录。")
|
||||
return
|
||||
delete_accounts_by_platform_conn(conn, key)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
for _rid, pdir in rows:
|
||||
_remove_profile_dir(pdir)
|
||||
print(
|
||||
f"✅ 已删除 {_PLATFORM_PRIMARY_CN.get(key, key)} 下共 {len(rows)} 条账号及对应用户数据目录。"
|
||||
)
|
||||
log.info("delete_by_platform_done platform=%s count=%s", key, len(rows))
|
||||
|
||||
|
||||
def cmd_delete_by_platform_phone(platform_input: str, phone: str) -> None:
|
||||
log = get_skill_logger()
|
||||
log.info("delete_by_platform_phone platform_input=%r", platform_input)
|
||||
init_db()
|
||||
key = resolve_platform_key((platform_input or "").strip())
|
||||
if not key:
|
||||
print("ERROR:INVALID_PLATFORM 无法识别的平台名称。")
|
||||
print("支持:" + _platform_list_cn_for_help())
|
||||
return
|
||||
phone_raw = (phone or "").strip()
|
||||
if not phone_raw:
|
||||
print("ERROR:PHONE_REQUIRED 手机号为必填。")
|
||||
return
|
||||
phone_norm = _normalize_phone_digits(phone_raw)
|
||||
if not _is_valid_cn_mobile11(phone_norm):
|
||||
print(
|
||||
"ERROR:PHONE_INVALID 手机号格式不正确:须为中国大陆 11 位号码,"
|
||||
"以 1 开头、第二位为 3~9。"
|
||||
)
|
||||
return
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = fetch_row_platform_phone_conn(conn, key, phone_norm)
|
||||
if not row:
|
||||
print("ERROR:ACCOUNT_NOT_FOUND 该平台下无此手机号。")
|
||||
return
|
||||
rid, name, profile_dir = row
|
||||
delete_account_platform_phone_conn(conn, key, phone_norm)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_remove_profile_dir(profile_dir)
|
||||
print(
|
||||
f"✅ 已删除账号:ID {rid} | {name} | {_PLATFORM_PRIMARY_CN.get(key, key)} | 手机 {phone_norm}"
|
||||
)
|
||||
log.info("delete_by_platform_phone_done id=%s platform=%s", rid, key)
|
||||
|
||||
|
||||
def cmd_set_login_status(account_id_str: str, status_str: str) -> None:
|
||||
"""
|
||||
跨技能机器接口:回写 accounts.login_status。
|
||||
成功:stdout 首行 OK:SET_LOGIN_STATUS 或 OK:CLEARED_LOGIN_STATUS,进程退出码 0。
|
||||
失败:stdout 首行 ERROR:...,退出码 1。
|
||||
"""
|
||||
aid_s = (account_id_str or "").strip()
|
||||
st_s = (status_str or "").strip()
|
||||
if not aid_s.isdigit():
|
||||
print("ERROR:INVALID_ACCOUNT_ID")
|
||||
sys.exit(1)
|
||||
if st_s not in ("0", "1"):
|
||||
print("ERROR:INVALID_STATUS 须为 0 或 1")
|
||||
sys.exit(1)
|
||||
aid = int(aid_s)
|
||||
init_db()
|
||||
if get_account_by_id(aid) is None:
|
||||
get_skill_logger().warning("set_login_status_not_found account_id=%r", aid)
|
||||
print("ERROR:ACCOUNT_NOT_FOUND")
|
||||
sys.exit(1)
|
||||
ok = st_s == "1"
|
||||
mark_login_status(aid, ok)
|
||||
get_skill_logger().info("set_login_status account_id=%s success=%s", aid, ok)
|
||||
print("OK:SET_LOGIN_STATUS" if ok else "OK:CLEARED_LOGIN_STATUS")
|
||||
__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",
|
||||
]
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
"""Chromium/Edge 检测与 Playwright 登录、仅打开浏览器。"""
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Chromium/Edge browser profile opener.
|
||||
|
||||
Uses system Chrome/Edge channel, Playwright launch_persistent_context.
|
||||
Does NOT download Playwright Chromium.
|
||||
Does NOT check login status.
|
||||
Does NOT write secret to SQLite.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from db.accounts_repo import get_account_by_id, mark_login_status
|
||||
from util.logging_config import get_skill_logger, get_skill_log_file_path
|
||||
from util.platforms import (
|
||||
PLATFORMS,
|
||||
PLATFORM_URLS,
|
||||
_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS,
|
||||
from db.connection import 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):
|
||||
@@ -26,13 +33,12 @@ def _win_find_exe(candidates):
|
||||
return None
|
||||
|
||||
|
||||
def resolve_chromium_channel():
|
||||
def resolve_chromium_executable():
|
||||
"""
|
||||
Playwright channel: 'chrome' | 'msedge' | None
|
||||
Windows 优先 Chrome,其次 Edge;其它系统默认 chrome(由 Playwright 解析 PATH)。
|
||||
Windows 上返回 chrome.exe 或 msedge.exe 的绝对路径;未找到或非 Windows 返回 None。
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return "chrome"
|
||||
return None
|
||||
|
||||
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||
pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
||||
@@ -46,7 +52,7 @@ def resolve_chromium_channel():
|
||||
]
|
||||
)
|
||||
if chrome:
|
||||
return "chrome"
|
||||
return chrome
|
||||
|
||||
edge = _win_find_exe(
|
||||
[
|
||||
@@ -56,201 +62,70 @@ def resolve_chromium_channel():
|
||||
]
|
||||
)
|
||||
if edge:
|
||||
return "msedge"
|
||||
return edge
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_chromium_channel():
|
||||
"""
|
||||
Playwright channel: 'chrome' | 'msedge' | None
|
||||
Windows 优先 Chrome,其次 Edge;其它系统默认 chrome(由 Playwright 解析 PATH)。
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return "chrome"
|
||||
|
||||
exe = resolve_chromium_executable()
|
||||
if not exe:
|
||||
return None
|
||||
if os.path.basename(exe).lower() == "msedge.exe":
|
||||
return "msedge"
|
||||
return "chrome"
|
||||
|
||||
|
||||
def _print_browser_install_hint():
|
||||
print("❌ 未检测到 Google Chrome 或 Microsoft Edge,请先安装后再登录:")
|
||||
print("❌ 未检测到 Google Chrome 或 Microsoft Edge,请先安装:")
|
||||
print(" • Chrome: https://www.google.com/chrome/")
|
||||
print(" • Edge: https://www.microsoft.com/zh-cn/edge")
|
||||
|
||||
|
||||
def _login_timeout_seconds():
|
||||
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:
|
||||
return max(60, int((os.getenv("JIANGCHANG_LOGIN_TIMEOUT_SECONDS") or "300").strip()))
|
||||
except ValueError:
|
||||
return 300
|
||||
|
||||
|
||||
def _login_poll_interval_seconds():
|
||||
"""轮询间隔,默认 1.5s。"""
|
||||
try:
|
||||
v = float((os.getenv("JIANGCHANG_LOGIN_POLL_INTERVAL_SECONDS") or "1.5").strip())
|
||||
return max(0.5, min(v, 10.0))
|
||||
except ValueError:
|
||||
return 1.5
|
||||
|
||||
|
||||
def _login_dom_grace_seconds() -> float:
|
||||
"""goto 后等待再开始 DOM 判定,减轻跳转中途误判。默认 4s,JIANGCHANG_LOGIN_DOM_GRACE_SECONDS。"""
|
||||
try:
|
||||
v = float((os.getenv("JIANGCHANG_LOGIN_DOM_GRACE_SECONDS") or "4").strip())
|
||||
return max(0.0, min(v, 60.0))
|
||||
except ValueError:
|
||||
return 4.0
|
||||
|
||||
|
||||
def _login_out_bundle_for(platform_key: str) -> dict:
|
||||
"""传给子进程:anchor + 未登录可见的选择器列表。"""
|
||||
spec = PLATFORMS.get(platform_key, {}) if platform_key else {}
|
||||
anchor = (urlparse(PLATFORM_URLS.get(platform_key, "") or "").netloc or "").lower()
|
||||
spec_lo_dom = [
|
||||
str(s).strip()
|
||||
for s in (spec.get("login_logged_out_selectors") or [])
|
||||
if s is not None and str(s).strip()
|
||||
]
|
||||
if bool(spec.get("login_skip_generic_logged_out_dom")):
|
||||
logged_out_dom_selectors = list(spec_lo_dom)
|
||||
else:
|
||||
logged_out_dom_selectors = list(_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS) + spec_lo_dom
|
||||
return {
|
||||
"logged_out_dom_selectors": logged_out_dom_selectors,
|
||||
"anchor_host": anchor,
|
||||
}
|
||||
|
||||
|
||||
def cmd_login(account_id):
|
||||
log = get_skill_logger()
|
||||
log.info("login_command account_id=%r", account_id)
|
||||
target = get_account_by_id(account_id)
|
||||
if not target:
|
||||
log.warning("login_aborted account_not_found account_id=%r", account_id)
|
||||
print("ERROR:ACCOUNT_NOT_FOUND")
|
||||
return
|
||||
|
||||
channel = resolve_chromium_channel()
|
||||
if not channel:
|
||||
log.warning("login_aborted no_chromium_channel")
|
||||
_print_browser_install_hint()
|
||||
return
|
||||
|
||||
try:
|
||||
import playwright # noqa: F401
|
||||
except ImportError:
|
||||
log.error("login_aborted playwright_missing")
|
||||
print("ERROR:需要 playwright:pip install playwright && playwright install chromium")
|
||||
return
|
||||
|
||||
profile_dir = (target.get("profile_dir") or "").strip()
|
||||
if not profile_dir:
|
||||
log.warning("login_aborted profile_dir_empty account_id=%s", target.get("id"))
|
||||
print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。")
|
||||
return
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
url = (target.get("url") or "").strip() or PLATFORM_URLS.get(target["platform"], "https://www.google.com")
|
||||
platform = (target.get("platform") or "").strip().lower()
|
||||
timeout_sec = _login_timeout_seconds()
|
||||
poll_sec = _login_poll_interval_seconds()
|
||||
dom_grace_sec = _login_dom_grace_seconds()
|
||||
|
||||
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
|
||||
print(f"正在为账号 [{target['name']}] 打开 {browser_name} …")
|
||||
print(f"访问地址:{url}")
|
||||
print(
|
||||
"请在窗口内完成登录。系统根据页面是否仍出现「登录」等未登录控件判断;"
|
||||
"成功后自动关闭窗口并写入状态(无需手动关浏览器)。"
|
||||
)
|
||||
print(
|
||||
f"页面加载后先等待约 {dom_grace_sec:g} 秒再开始检测(减轻跳转误判,可用 JIANGCHANG_LOGIN_DOM_GRACE_SECONDS 调整)。"
|
||||
)
|
||||
print(f"轮询间隔约 {poll_sec} 秒;全程最长 {timeout_sec} 秒。")
|
||||
|
||||
log_file = get_skill_log_file_path()
|
||||
log.info(
|
||||
"login_browser_start account_id=%s platform=%s channel=%s timeout_sec=%s poll_sec=%s "
|
||||
"dom_grace_sec=%s profile_dir=%s start_url=%s log_file=%s",
|
||||
target.get("id"),
|
||||
platform,
|
||||
channel,
|
||||
timeout_sec,
|
||||
poll_sec,
|
||||
dom_grace_sec,
|
||||
profile_dir,
|
||||
url,
|
||||
log_file,
|
||||
)
|
||||
login_runner_path = os.path.join(_SERVICE_DIR, "login_child_runner.py")
|
||||
cfg = {
|
||||
"channel": channel,
|
||||
"profile_dir": profile_dir,
|
||||
"url": url,
|
||||
"timeout_sec": timeout_sec,
|
||||
"poll_interval": poll_sec,
|
||||
"dom_grace_sec": dom_grace_sec,
|
||||
"login_detect_bundle": _login_out_bundle_for(platform),
|
||||
"log_file": log_file,
|
||||
}
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
||||
) as rf:
|
||||
result_path = rf.name
|
||||
cfg["result_path"] = result_path
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
||||
) as jf:
|
||||
json.dump(cfg, jf, ensure_ascii=False)
|
||||
cfg_path = jf.name
|
||||
proc_rc = None
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[sys.executable, login_runner_path, cfg_path],
|
||||
timeout=timeout_sec + 180,
|
||||
)
|
||||
proc_rc = r.returncode
|
||||
if proc_rc != 0:
|
||||
print("⚠️ 浏览器进程异常退出,将仅根据已写入的检测结果更新状态")
|
||||
log.warning("login_subprocess_nonzero_return code=%s", proc_rc)
|
||||
except subprocess.TimeoutExpired as ex:
|
||||
print("⚠️ 等待浏览器超时,将仅根据已写入的检测结果更新状态")
|
||||
log.warning("login_subprocess_timeout err=%s", ex)
|
||||
plat = get_platform_from_db(conn, pk)
|
||||
if plat and (plat.get("default_url") or "").strip():
|
||||
return plat["default_url"].strip()
|
||||
finally:
|
||||
try:
|
||||
os.unlink(cfg_path)
|
||||
except OSError:
|
||||
pass
|
||||
if close_conn:
|
||||
conn.close()
|
||||
|
||||
interactive_ok = False
|
||||
try:
|
||||
with open(result_path, encoding="utf-8") as rf:
|
||||
interactive_ok = bool(json.load(rf).get("interactive_ok"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.unlink(result_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
time.sleep(0.5)
|
||||
ok = interactive_ok
|
||||
log.info(
|
||||
"login_finished account_id=%s interactive_ok=%s subprocess_rc=%s marking_db=%s",
|
||||
target.get("id"),
|
||||
interactive_ok,
|
||||
proc_rc,
|
||||
ok,
|
||||
)
|
||||
mark_login_status(target["id"], ok)
|
||||
if ok:
|
||||
print("✅ 已判定登录成功,状态已写入数据库")
|
||||
else:
|
||||
log.warning(
|
||||
"login_not_detected account_id=%s platform=%s see_log=%s",
|
||||
target.get("id"),
|
||||
platform,
|
||||
log_file,
|
||||
)
|
||||
print("⚠️ 未检测到有效登录,状态为未登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。")
|
||||
print(f"ℹ️ 详细轮询日志见:{log_file}(可将 JIANGCHANG_LOG_LEVEL=DEBUG 打开更细粒度)")
|
||||
return (PLATFORMS.get(pk, {}).get("default_url") or "").strip()
|
||||
|
||||
|
||||
def cmd_open(account_id):
|
||||
"""打开该账号的持久化浏览器,仅用于肉眼确认是否已登录;不写数据库。"""
|
||||
get_skill_logger().info("open account_id=%r", account_id)
|
||||
"""
|
||||
打开该账号的持久化浏览器,仅用于肉眼核对。
|
||||
不写数据库、不判定是否已登录。
|
||||
"""
|
||||
log = get_skill_logger()
|
||||
init_db()
|
||||
log.info("browser_open_start account_id=%r", account_id)
|
||||
|
||||
target = get_account_by_id(account_id)
|
||||
if not target:
|
||||
get_skill_logger().warning("open_not_found account_id=%r", account_id)
|
||||
log.warning("browser_open_error account_id=%r not_found", account_id)
|
||||
print("ERROR:ACCOUNT_NOT_FOUND")
|
||||
return
|
||||
|
||||
@@ -262,24 +137,37 @@ def cmd_open(account_id):
|
||||
try:
|
||||
import playwright # noqa: F401
|
||||
except ImportError:
|
||||
print("ERROR:需要 playwright:pip install playwright && playwright install chromium")
|
||||
print(
|
||||
"ERROR:需要 playwright Python 包。"
|
||||
)
|
||||
return
|
||||
|
||||
profile_dir = (target.get("profile_dir") or "").strip()
|
||||
if not profile_dir:
|
||||
print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。")
|
||||
log.warning("browser_open_error account_id=%s profile_dir_missing", account_id)
|
||||
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
|
||||
return
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
url = (target.get("url") or "").strip() or PLATFORM_URLS.get(
|
||||
target["platform"], "https://www.google.com"
|
||||
)
|
||||
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
|
||||
print(f"正在打开 [{target['name']}] 的 {browser_name}(仅查看,不写入数据库)")
|
||||
print(f"地址:{url}")
|
||||
print("请在本窗口中自行确认登录态。关闭浏览器后命令结束。")
|
||||
print("需要自动检测并写入数据库时,请执行:python main.py login <id>")
|
||||
|
||||
open_runner_path = os.path.join(_SERVICE_DIR, "open_child_runner.py")
|
||||
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"
|
||||
@@ -287,7 +175,15 @@ def cmd_open(account_id):
|
||||
json.dump(cfg, jf, ensure_ascii=False)
|
||||
cfg_path = jf.name
|
||||
try:
|
||||
subprocess.run([sys.executable, open_runner_path, cfg_path])
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "service.open_child_runner", cfg_path],
|
||||
cwd=_SCRIPTS_ROOT,
|
||||
env=subprocess_env_with_trace(),
|
||||
)
|
||||
log.info("browser_open_close account_id=%s channel=%s", account_id, channel)
|
||||
except Exception as e:
|
||||
log.error("browser_open_error account_id=%s error=%s", account_id, str(e))
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
os.unlink(cfg_path)
|
||||
|
||||
121
scripts/service/credential_commands.py
Normal file
121
scripts/service/credential_commands.py
Normal 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
36
scripts/service/crypto.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Fernet 加密 / 解密的薄封装。
|
||||
|
||||
不暴露 Fernet 对象;调用方只看 encrypt/decrypt 字符串。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from service.master_key import load_master_key
|
||||
from service.secret_store import mask_secret
|
||||
|
||||
|
||||
class CredentialDecryptError(Exception):
|
||||
"""Decryption failed (bad key or corrupted ciphertext)."""
|
||||
|
||||
code = "ERR_CREDENTIAL_DECRYPT_FAILED"
|
||||
|
||||
|
||||
def encrypt_secret(plaintext: str) -> str:
|
||||
"""Encrypt using current master key; returns url-safe token string."""
|
||||
key = load_master_key()
|
||||
f = Fernet(key)
|
||||
tok = f.encrypt(plaintext.encode("utf-8"))
|
||||
return tok.decode("ascii")
|
||||
|
||||
|
||||
def decrypt_secret(ciphertext: str) -> str:
|
||||
"""Decrypt Fernet token string to plaintext."""
|
||||
key = load_master_key()
|
||||
f = Fernet(key)
|
||||
try:
|
||||
raw = f.decrypt(ciphertext.encode("ascii"))
|
||||
return raw.decode("utf-8")
|
||||
except InvalidToken as e:
|
||||
raise CredentialDecryptError("Fernet decrypt failed") from e
|
||||
34
scripts/service/lease_commands.py
Normal file
34
scripts/service/lease_commands.py
Normal 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}))
|
||||
128
scripts/service/legacy_commands.py
Normal file
128
scripts/service/legacy_commands.py
Normal 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("")
|
||||
@@ -1,223 +0,0 @@
|
||||
"""Playwright 登录检测子进程:由 browser_service.cmd_login 以独立解释器启动,argv[1] 为 JSON 配置路径。"""
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
|
||||
def page_location_href(p):
|
||||
# Prefer location.href over page.url for SPA (e.g. Sohu shell path lag).
|
||||
try:
|
||||
href = p.evaluate("() => location.href")
|
||||
if href and str(href).strip():
|
||||
return str(href).strip()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return (p.url or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def host_matches_anchor(netloc, anchor):
|
||||
h = (netloc or "").lower().strip()
|
||||
a = (anchor or "").lower().strip()
|
||||
if not a:
|
||||
return True
|
||||
if not h:
|
||||
return False
|
||||
return h == a or h.endswith("." + a) or a.endswith("." + h)
|
||||
|
||||
|
||||
def loc_first_visible(pg, selectors):
|
||||
for sel in selectors:
|
||||
s = str(sel).strip()
|
||||
if not s:
|
||||
continue
|
||||
try:
|
||||
loc = pg.locator(s).first
|
||||
if loc.is_visible(timeout=500):
|
||||
return True, s
|
||||
except Exception:
|
||||
pass
|
||||
return False, None
|
||||
|
||||
|
||||
def list_pages_on_anchor(ctx, main_page, bundle):
|
||||
anchor = (bundle.get("anchor_host") or "").strip().lower()
|
||||
seen_ids = set()
|
||||
pages = []
|
||||
for pg in list(ctx.pages or []):
|
||||
try:
|
||||
i = id(pg)
|
||||
if i not in seen_ids:
|
||||
seen_ids.add(i)
|
||||
pages.append(pg)
|
||||
except Exception:
|
||||
pages.append(pg)
|
||||
if main_page is not None:
|
||||
try:
|
||||
i = id(main_page)
|
||||
if i not in seen_ids:
|
||||
seen_ids.add(i)
|
||||
pages.append(main_page)
|
||||
except Exception:
|
||||
pages.append(main_page)
|
||||
out = []
|
||||
for pg in pages:
|
||||
try:
|
||||
href = page_location_href(pg)
|
||||
except Exception:
|
||||
continue
|
||||
if not href or str(href).lower().startswith("about:"):
|
||||
continue
|
||||
try:
|
||||
host = urlparse(href).netloc.lower()
|
||||
except Exception:
|
||||
continue
|
||||
if anchor and not host_matches_anchor(host, anchor):
|
||||
continue
|
||||
out.append(pg)
|
||||
return out
|
||||
|
||||
|
||||
def evaluate_logged_out_dom(ctx, main_page, bundle):
|
||||
selectors = [
|
||||
str(x).strip()
|
||||
for x in (bundle.get("logged_out_dom_selectors") or [])
|
||||
if x is not None and str(x).strip()
|
||||
]
|
||||
if not selectors:
|
||||
return True, "no_logged_out_dom_selectors_configured"
|
||||
for pg in list_pages_on_anchor(ctx, main_page, bundle):
|
||||
try:
|
||||
href = page_location_href(pg)
|
||||
except Exception:
|
||||
href = ""
|
||||
hit, which = loc_first_visible(pg, selectors)
|
||||
if hit:
|
||||
return True, "logged_out_dom=%r url=%r" % (which, href)
|
||||
return False, ""
|
||||
|
||||
|
||||
def main():
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
c = json.load(f)
|
||||
bundle = c.get("login_detect_bundle") or {}
|
||||
poll = float(c.get("poll_interval", 1.5))
|
||||
try:
|
||||
dom_grace = float(c.get("dom_grace_sec", 4.0))
|
||||
except (TypeError, ValueError):
|
||||
dom_grace = 4.0
|
||||
if dom_grace < 0:
|
||||
dom_grace = 0.0
|
||||
t0 = time.time()
|
||||
deadline = t0 + float(c["timeout_sec"])
|
||||
interactive_ok = False
|
||||
result_path = c.get("result_path") or ""
|
||||
log_path = (c.get("log_file") or "").strip()
|
||||
lg = logging.getLogger("openclaw.skill.account_manager.login_child")
|
||||
if log_path:
|
||||
lg.handlers.clear()
|
||||
lg.setLevel(logging.DEBUG)
|
||||
_fh = logging.FileHandler(log_path, encoding="utf-8")
|
||||
_fh.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||
)
|
||||
)
|
||||
lg.addHandler(_fh)
|
||||
lg.propagate = False
|
||||
last_eval_detail = ""
|
||||
with sync_playwright() as p:
|
||||
ctx = p.chromium.launch_persistent_context(
|
||||
user_data_dir=c["profile_dir"],
|
||||
headless=False,
|
||||
channel=c["channel"],
|
||||
no_viewport=True,
|
||||
args=["--start-maximized"],
|
||||
)
|
||||
try:
|
||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||
page.goto(c["url"], wait_until="domcontentloaded", timeout=60000)
|
||||
if dom_grace > 0:
|
||||
time.sleep(dom_grace)
|
||||
if lg.handlers:
|
||||
lg.info(
|
||||
"goto_done initial_pages=%s poll_interval_sec=%s dom_grace_sec=%s",
|
||||
len(ctx.pages or []),
|
||||
poll,
|
||||
dom_grace,
|
||||
)
|
||||
while time.time() < deadline:
|
||||
iter_start = time.time()
|
||||
try:
|
||||
still_logged_out, detail = evaluate_logged_out_dom(ctx, page, bundle)
|
||||
ok = not still_logged_out
|
||||
last_eval_detail = detail
|
||||
if lg.handlers:
|
||||
parts = []
|
||||
for tab in list(ctx.pages or []):
|
||||
try:
|
||||
href = page_location_href(tab)
|
||||
pu = (tab.url or "").strip()
|
||||
parts.append("href=%r playwright_url=%r" % (href, pu))
|
||||
except Exception:
|
||||
parts.append("(tab_read_error)")
|
||||
lg.debug(
|
||||
"poll iter_start_elapsed=%.1fs deadline_in=%.1fs tabs=%s poll_interval_sec=%s logged_out=%s ok=%s detail=%s | %s",
|
||||
iter_start - t0,
|
||||
deadline - iter_start,
|
||||
len(ctx.pages or []),
|
||||
poll,
|
||||
still_logged_out,
|
||||
ok,
|
||||
detail,
|
||||
" ; ".join(parts) if parts else "no_tabs",
|
||||
)
|
||||
if ok:
|
||||
interactive_ok = True
|
||||
if lg.handlers:
|
||||
lg.info("login_detected_ok %s", detail)
|
||||
break
|
||||
except Exception as ex:
|
||||
if lg.handlers:
|
||||
lg.warning("poll_exception err=%s", ex, exc_info=True)
|
||||
spent = time.time() - iter_start
|
||||
time.sleep(max(0.0, poll - spent))
|
||||
if not interactive_ok:
|
||||
if lg.handlers:
|
||||
lg.warning("login_poll_exhausted detail=%s", last_eval_detail)
|
||||
rem = max(0.0, deadline - time.time())
|
||||
if rem > 0:
|
||||
try:
|
||||
ctx.wait_for_event("close", timeout=rem * 1000)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
if lg.handlers:
|
||||
lg.info("login_success_closing_browser_immediately")
|
||||
except Exception as e:
|
||||
if lg.handlers:
|
||||
lg.exception("login_runner_fatal err=%s", e)
|
||||
print(e, file=sys.stderr)
|
||||
raise
|
||||
finally:
|
||||
if result_path:
|
||||
try:
|
||||
with open(result_path, "w", encoding="utf-8") as rf:
|
||||
json.dump({"interactive_ok": interactive_ok}, rf, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ctx.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
142
scripts/service/master_key.py
Normal file
142
scripts/service/master_key.py
Normal file
@@ -0,0 +1,142 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""master key 加载与生成。
|
||||
|
||||
加载优先级(高到低):
|
||||
1. 环境变量 ACCOUNT_MANAGER_MASTER_KEY(base64-urlsafe 字符串,44 字符)
|
||||
2. 文件 {DATA_ROOT}/{USER_ID}/account-manager/master.key
|
||||
3. 都没有 → raise MasterKeyMissingError,错误码 ERR_MASTER_KEY_MISSING
|
||||
|
||||
不再自动生成——必须显式 `account init-master-key`。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from util.runtime_paths import get_skill_data_dir
|
||||
|
||||
ENV_MASTER_KEY = "ACCOUNT_MANAGER_MASTER_KEY"
|
||||
_MASTER_FILENAME = "master.key"
|
||||
|
||||
|
||||
class MasterKeyMissingError(Exception):
|
||||
"""Raised when no valid master key can be resolved."""
|
||||
|
||||
code = "ERR_MASTER_KEY_MISSING"
|
||||
|
||||
|
||||
class MasterKeyExistsError(Exception):
|
||||
"""Raised when master.key already exists and overwrite is not allowed."""
|
||||
|
||||
code = "ERR_MASTER_KEY_EXISTS"
|
||||
|
||||
|
||||
class MasterKeyInvalidError(Exception):
|
||||
"""Raised when a supplied master key string cannot be used as a Fernet key."""
|
||||
|
||||
code = "ERR_MASTER_KEY_INVALID"
|
||||
|
||||
|
||||
def master_key_path() -> str:
|
||||
"""Absolute path to master.key alongside account-manager.db."""
|
||||
return os.path.join(get_skill_data_dir(), _MASTER_FILENAME)
|
||||
|
||||
|
||||
def _try_fernet_key(key_material: bytes) -> bytes | None:
|
||||
"""Return url-safe key bytes accepted by Fernet, or None if invalid."""
|
||||
try:
|
||||
raw = key_material.strip()
|
||||
if not raw:
|
||||
return None
|
||||
# Accept stored form: ascii urlsafe base64 string as bytes
|
||||
Fernet(raw)
|
||||
return raw
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _try_decode_manual_b64(s: str) -> bytes | None:
|
||||
"""If string decodes to 32 raw bytes, re-encode as Fernet url-safe key."""
|
||||
try:
|
||||
pad = "=" * ((4 - len(s) % 4) % 4)
|
||||
decoded = base64.urlsafe_b64decode(s + pad)
|
||||
if len(decoded) != 32:
|
||||
return None
|
||||
key_bytes = base64.urlsafe_b64encode(decoded)
|
||||
Fernet(key_bytes)
|
||||
return key_bytes
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_key_string(s: str) -> bytes | None:
|
||||
s = (s or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
fk = _try_fernet_key(s.encode("ascii"))
|
||||
if fk is not None:
|
||||
return fk
|
||||
return _try_decode_manual_b64(s)
|
||||
|
||||
|
||||
def load_master_key() -> bytes:
|
||||
"""Resolve master key (env → file); raises MasterKeyMissingError if unavailable."""
|
||||
env_val = (os.environ.get(ENV_MASTER_KEY) or "").strip()
|
||||
if env_val:
|
||||
kb = _normalize_key_string(env_val)
|
||||
if kb is not None:
|
||||
return kb
|
||||
|
||||
path = master_key_path()
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
txt = raw.strip().decode("ascii", errors="strict")
|
||||
kb = _normalize_key_string(txt)
|
||||
if kb is not None:
|
||||
return kb
|
||||
except OSError:
|
||||
pass
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
raise MasterKeyMissingError("No valid ACCOUNT_MANAGER_MASTER_KEY or master.key")
|
||||
|
||||
|
||||
def normalize_master_key_input(s: str) -> bytes:
|
||||
"""Validate user-supplied key text; raises MasterKeyInvalidError if unusable."""
|
||||
kb = _normalize_key_string((s or "").strip())
|
||||
if kb is None:
|
||||
raise MasterKeyInvalidError("invalid master key material")
|
||||
return kb
|
||||
|
||||
|
||||
def generate_master_key() -> bytes:
|
||||
"""Return a new Fernet key (44-char url-safe base64 as bytes)."""
|
||||
return Fernet.generate_key()
|
||||
|
||||
|
||||
def write_master_key_file(key: bytes, *, allow_overwrite: bool = False) -> str:
|
||||
"""Write key bytes to master_key_path(); chmod 0o600 best-effort."""
|
||||
path = master_key_path()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
if os.path.exists(path) and not allow_overwrite:
|
||||
raise MasterKeyExistsError("master.key already exists; use --rotate to overwrite")
|
||||
data = key.strip()
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
def is_master_key_available() -> bool:
|
||||
try:
|
||||
load_master_key()
|
||||
return True
|
||||
except MasterKeyMissingError:
|
||||
return False
|
||||
@@ -4,18 +4,30 @@ import sys
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from util.playwright_stealth import (
|
||||
STEALTH_INIT_SCRIPT,
|
||||
persistent_context_launch_parts,
|
||||
stealth_enabled,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
c = json.load(f)
|
||||
with sync_playwright() as p:
|
||||
ctx = p.chromium.launch_persistent_context(
|
||||
args, ignore_automation = persistent_context_launch_parts()
|
||||
lc_kw = dict(
|
||||
user_data_dir=c["profile_dir"],
|
||||
headless=False,
|
||||
channel=c["channel"],
|
||||
no_viewport=True,
|
||||
args=["--start-maximized"],
|
||||
args=args,
|
||||
)
|
||||
if ignore_automation is not None:
|
||||
lc_kw["ignore_default_args"] = ignore_automation
|
||||
ctx = p.chromium.launch_persistent_context(**lc_kw)
|
||||
if stealth_enabled():
|
||||
ctx.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
try:
|
||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||
page.goto(c["url"], wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
101
scripts/service/platform_commands.py
Normal file
101
scripts/service/platform_commands.py
Normal 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
|
||||
20
scripts/service/rpa_helpers/__init__.py
Normal file
20
scripts/service/rpa_helpers/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""account-manager 提供给业务 skill 的 RPA helper 库。
|
||||
|
||||
业务 skill 同进程 import 使用,不通过 CLI subprocess。
|
||||
"""
|
||||
from .auth_flows import ( # noqa: F401
|
||||
AuthStrategyError,
|
||||
LoginResult,
|
||||
ensure_logged_in,
|
||||
ensure_logged_in_api_token,
|
||||
ensure_logged_in_client_certificate,
|
||||
ensure_logged_in_device_bound,
|
||||
ensure_logged_in_password_auto,
|
||||
ensure_logged_in_password_plus_2fa,
|
||||
ensure_logged_in_password_with_captcha,
|
||||
ensure_logged_in_per_session_manual,
|
||||
ensure_logged_in_qr_code_manual,
|
||||
ensure_logged_in_sso_redirect,
|
||||
)
|
||||
from .browser import close_browser, launch_browser_with_profile # noqa: F401
|
||||
from .human import human_click, human_scroll_to, human_select, human_type, pause # noqa: F401
|
||||
451
scripts/service/rpa_helpers/auth_flows.py
Normal file
451
scripts/service/rpa_helpers/auth_flows.py
Normal file
@@ -0,0 +1,451 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Auth strategy helpers: ensure_logged_in_* + dispatcher (no DB / no CLI)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .human import human_click, human_type
|
||||
|
||||
|
||||
def _poll_until(
|
||||
predicate,
|
||||
timeout_sec: int,
|
||||
interval_sec: float = 2.0,
|
||||
) -> bool:
|
||||
"""轮询 predicate 直到返回 True 或超时。"""
|
||||
deadline = time.time() + timeout_sec
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
if predicate():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(interval_sec)
|
||||
return False
|
||||
|
||||
|
||||
def _stderr_prompt(msg: str) -> None:
|
||||
sys.stderr.write(f"[auth_flows] {msg}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginResult:
|
||||
ok: bool
|
||||
needs_human: bool
|
||||
message: str
|
||||
|
||||
|
||||
class AuthStrategyError(Exception):
|
||||
"""auth_strategy 相关错误(不可自动化 / 不支持 / fingerprint 不匹配等)。"""
|
||||
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def _account_extra(account: dict) -> dict:
|
||||
ex = account.get("extra_json")
|
||||
if ex is None:
|
||||
return {}
|
||||
if isinstance(ex, dict):
|
||||
return ex
|
||||
if isinstance(ex, str):
|
||||
try:
|
||||
return json.loads(ex)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _is_already_logged_in(page, account: dict) -> bool:
|
||||
"""是否已登录:仅当 extra_json 提供正向证据(URL 正则或可见 selector)时为 True。"""
|
||||
try:
|
||||
cur = page.url or ""
|
||||
except Exception:
|
||||
return False
|
||||
cur_stripped = cur.strip()
|
||||
if not cur_stripped:
|
||||
return False
|
||||
ul = cur_stripped.lower()
|
||||
if ul == "about:blank" or ul.startswith("about:blank"):
|
||||
return False
|
||||
|
||||
extra = _account_extra(account)
|
||||
|
||||
pattern = extra.get("logged_in_url_pattern")
|
||||
if isinstance(pattern, str) and pattern.strip():
|
||||
try:
|
||||
if re.search(pattern, cur_stripped):
|
||||
return True
|
||||
except re.error:
|
||||
pass
|
||||
|
||||
sel = extra.get("logged_in_selector")
|
||||
if isinstance(sel, str) and sel.strip():
|
||||
try:
|
||||
if page.locator(sel.strip()).first.is_visible(timeout=500):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _current_machine_fingerprint() -> str:
|
||||
"""hostname + MAC 的 sha256 前 16 位。"""
|
||||
h = hashlib.sha256()
|
||||
h.update(socket.gethostname().encode("utf-8"))
|
||||
h.update(str(uuid.getnode()).encode("utf-8"))
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
|
||||
def _login_selectors(account: dict) -> tuple[str, str, str]:
|
||||
extra = _account_extra(account)
|
||||
selectors = extra.get("login_selectors") or {}
|
||||
login_id_sel = (
|
||||
selectors.get("login_id")
|
||||
or "input[name*='user'], input[name*='login'], input[name*='account'], input[type='email']"
|
||||
)
|
||||
password_sel = selectors.get("password") or "input[type='password']"
|
||||
submit_sel = (
|
||||
selectors.get("submit")
|
||||
or "button[type='submit'], button:has-text('登录'), button:has-text('Sign in')"
|
||||
)
|
||||
return str(login_id_sel), str(password_sel), str(submit_sel)
|
||||
|
||||
|
||||
def _require_login_id(account: dict) -> str | None:
|
||||
lid = account.get("login_id")
|
||||
if lid is None:
|
||||
return None
|
||||
s = str(lid).strip()
|
||||
return s or None
|
||||
|
||||
|
||||
def ensure_logged_in_password_auto(page, account: dict, password: str, **_kwargs) -> LoginResult:
|
||||
if _is_already_logged_in(page, account):
|
||||
return LoginResult(
|
||||
ok=True,
|
||||
needs_human=False,
|
||||
message="already logged in (profile reused)",
|
||||
)
|
||||
url = account.get("url") or ""
|
||||
if url:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
login_id = _require_login_id(account)
|
||||
if not login_id:
|
||||
return LoginResult(ok=False, needs_human=False, message="login_id missing in account")
|
||||
|
||||
lid_sel, pwd_sel, sub_sel = _login_selectors(account)
|
||||
human_type(page.locator(lid_sel).first, login_id)
|
||||
human_type(page.locator(pwd_sel).first, password)
|
||||
human_click(page.locator(sub_sel).first)
|
||||
|
||||
ok = _poll_until(lambda: _is_already_logged_in(page, account), 30, interval_sec=1.0)
|
||||
return LoginResult(
|
||||
ok=ok,
|
||||
needs_human=False,
|
||||
message="login succeeded" if ok else "login timeout",
|
||||
)
|
||||
|
||||
|
||||
def ensure_logged_in_password_with_captcha(
|
||||
page,
|
||||
account: dict,
|
||||
password: str,
|
||||
*,
|
||||
captcha_value: str | None = None,
|
||||
wait_human_sec: int = 60,
|
||||
**_kwargs,
|
||||
) -> LoginResult:
|
||||
if _is_already_logged_in(page, account):
|
||||
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
|
||||
url = account.get("url") or ""
|
||||
if url:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
login_id = _require_login_id(account)
|
||||
if not login_id:
|
||||
return LoginResult(ok=False, needs_human=False, message="login_id missing in account")
|
||||
|
||||
extra = _account_extra(account)
|
||||
selectors = extra.get("login_selectors") or {}
|
||||
captcha_sel = (
|
||||
selectors.get("captcha") or "input[name*='captcha'], input[name*='verify_code']"
|
||||
)
|
||||
lid_sel, pwd_sel, sub_sel = _login_selectors(account)
|
||||
|
||||
human_type(page.locator(lid_sel).first, login_id)
|
||||
human_type(page.locator(pwd_sel).first, password)
|
||||
|
||||
if captcha_value is not None:
|
||||
human_type(page.locator(captcha_sel).first, str(captcha_value))
|
||||
human_click(page.locator(sub_sel).first)
|
||||
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=1.0)
|
||||
return LoginResult(
|
||||
ok=ok,
|
||||
needs_human=False,
|
||||
message="login succeeded" if ok else "captcha flow timeout",
|
||||
)
|
||||
|
||||
_stderr_prompt("请输入图形验证码后回车继续(CTRL+C 取消)")
|
||||
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=1.0)
|
||||
return LoginResult(
|
||||
ok=ok,
|
||||
needs_human=True,
|
||||
message="captcha human assist completed" if ok else "captcha human assist timeout",
|
||||
)
|
||||
|
||||
|
||||
def _is_2fa_page(page) -> bool:
|
||||
try:
|
||||
u = (page.url or "").lower()
|
||||
if any(x in u for x in ("2fa", "verify", "mfa")):
|
||||
return True
|
||||
otp = page.locator("input[name*='code'], input[name*='otp']")
|
||||
if otp.count() > 0:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def ensure_logged_in_password_plus_2fa(
|
||||
page,
|
||||
account: dict,
|
||||
password: str,
|
||||
*,
|
||||
wait_human_sec: int = 120,
|
||||
**_kwargs,
|
||||
) -> LoginResult:
|
||||
if _is_already_logged_in(page, account):
|
||||
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
|
||||
url = account.get("url") or ""
|
||||
if url:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
login_id = _require_login_id(account)
|
||||
if not login_id:
|
||||
return LoginResult(ok=False, needs_human=False, message="login_id missing in account")
|
||||
|
||||
lid_sel, pwd_sel, sub_sel = _login_selectors(account)
|
||||
human_type(page.locator(lid_sel).first, login_id)
|
||||
human_type(page.locator(pwd_sel).first, password)
|
||||
human_click(page.locator(sub_sel).first)
|
||||
|
||||
_poll_until(lambda: _is_2fa_page(page) or _is_already_logged_in(page, account), 30, interval_sec=1.0)
|
||||
|
||||
if _is_already_logged_in(page, account):
|
||||
return LoginResult(ok=True, needs_human=False, message="login succeeded without 2FA")
|
||||
|
||||
_stderr_prompt("请输入 2FA 验证码并提交")
|
||||
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=1.0)
|
||||
msg = "2FA completed" if ok else "2FA timeout ERR_LOGIN_2FA_TIMEOUT"
|
||||
return LoginResult(ok=ok, needs_human=True, message=msg)
|
||||
|
||||
|
||||
def _qr_locator(page):
|
||||
return page.locator("canvas, img[src*='qr'], img[alt*='二维码']")
|
||||
|
||||
|
||||
def ensure_logged_in_qr_code_manual(page, account: dict, *, wait_human_sec: int = 300, **_kwargs) -> LoginResult:
|
||||
if _is_already_logged_in(page, account):
|
||||
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
|
||||
url = account.get("url") or ""
|
||||
if url:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
qr = _qr_locator(page)
|
||||
try:
|
||||
qr.first.wait_for(state="visible", timeout=30000)
|
||||
except Exception:
|
||||
return LoginResult(ok=False, needs_human=True, message="qr code not found")
|
||||
|
||||
_stderr_prompt("请用手机扫描浏览器中的二维码")
|
||||
|
||||
def _qr_done() -> bool:
|
||||
if _is_already_logged_in(page, account):
|
||||
return True
|
||||
try:
|
||||
av = page.locator("img[alt*='avatar']")
|
||||
if av.count() > 0 and av.first.is_visible(timeout=300):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
ok = _poll_until(_qr_done, wait_human_sec, interval_sec=2.0)
|
||||
return LoginResult(
|
||||
ok=ok,
|
||||
needs_human=True,
|
||||
message="qr scan completed" if ok else "qr scan timeout",
|
||||
)
|
||||
|
||||
|
||||
def ensure_logged_in_per_session_manual(
|
||||
page,
|
||||
account: dict,
|
||||
*,
|
||||
wait_human_sec: int = 600,
|
||||
**_kwargs,
|
||||
) -> LoginResult:
|
||||
url = account.get("url") or ""
|
||||
if url:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
_stderr_prompt(f"请完整登录后等待 RPA 继续(最长 {wait_human_sec} 秒)")
|
||||
|
||||
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=2.0)
|
||||
return LoginResult(
|
||||
ok=ok,
|
||||
needs_human=True,
|
||||
message="per-session manual completed" if ok else "per-session manual timeout",
|
||||
)
|
||||
|
||||
|
||||
def ensure_logged_in_device_bound(
|
||||
page,
|
||||
account: dict,
|
||||
password: str | None = None,
|
||||
*,
|
||||
wait_human_sec: int = 120,
|
||||
**_kwargs,
|
||||
) -> LoginResult:
|
||||
expected_fp = account.get("device_fingerprint")
|
||||
if expected_fp:
|
||||
cur = _current_machine_fingerprint()
|
||||
if cur != str(expected_fp):
|
||||
raise AuthStrategyError(
|
||||
"ERR_DEVICE_FINGERPRINT_MISMATCH",
|
||||
f"current machine fingerprint {cur!r} does not match account's {expected_fp!r}",
|
||||
)
|
||||
if password:
|
||||
return ensure_logged_in_password_auto(page, account, password)
|
||||
return ensure_logged_in_per_session_manual(page, account, wait_human_sec=wait_human_sec)
|
||||
|
||||
|
||||
def ensure_logged_in_client_certificate(page, account: dict, **_kwargs) -> LoginResult:
|
||||
raise AuthStrategyError(
|
||||
"ERR_AUTH_STRATEGY_NOT_AUTOMATABLE",
|
||||
"client_certificate strategy requires manual cert + PIN; not automatable by rpa_helpers",
|
||||
)
|
||||
|
||||
|
||||
def ensure_logged_in_api_token(page, account: dict, **_kwargs) -> LoginResult:
|
||||
return LoginResult(ok=True, needs_human=False, message="api_token: no browser login required")
|
||||
|
||||
|
||||
def _default_sso_hosts(account: dict) -> list[str]:
|
||||
ex = _account_extra(account)
|
||||
hosts = ex.get("sso_redirect_hosts")
|
||||
if isinstance(hosts, list) and hosts:
|
||||
return [str(h) for h in hosts if h]
|
||||
return ["accounts.google.com", "login.microsoftonline.com", "okta.com"]
|
||||
|
||||
|
||||
def _url_has_host_fragment(url: str, hosts: list[str]) -> bool:
|
||||
u = (url or "").lower()
|
||||
return any(h.lower() in u for h in hosts)
|
||||
|
||||
|
||||
def ensure_logged_in_sso_redirect(
|
||||
page,
|
||||
account: dict,
|
||||
*,
|
||||
wait_human_sec: int = 300,
|
||||
**_kwargs,
|
||||
) -> LoginResult:
|
||||
if _is_already_logged_in(page, account):
|
||||
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
|
||||
url = account.get("url") or ""
|
||||
if not url:
|
||||
return LoginResult(ok=False, needs_human=True, message="account url missing for SSO flow")
|
||||
|
||||
target_netloc = (urlparse(url).netloc or "").lower()
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
hosts = _default_sso_hosts(account)
|
||||
seen_sso = _poll_until(lambda: _url_has_host_fragment(page.url or "", hosts), min(60, wait_human_sec), 2.0)
|
||||
if not seen_sso:
|
||||
return LoginResult(ok=False, needs_human=True, message="sso redirect not detected")
|
||||
|
||||
_stderr_prompt("请在弹出的 SSO 页面完成登录")
|
||||
|
||||
def _back_home() -> bool:
|
||||
try:
|
||||
cur = (urlparse(page.url).netloc or "").lower()
|
||||
if target_netloc and cur == target_netloc:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return _is_already_logged_in(page, account)
|
||||
|
||||
ok = _poll_until(_back_home, wait_human_sec, interval_sec=2.0)
|
||||
return LoginResult(
|
||||
ok=ok,
|
||||
needs_human=True,
|
||||
message="sso completed" if ok else "sso timeout",
|
||||
)
|
||||
|
||||
|
||||
def ensure_logged_in(
|
||||
page,
|
||||
account: dict,
|
||||
*,
|
||||
credentials: dict | None = None,
|
||||
**kwargs,
|
||||
) -> LoginResult:
|
||||
"""根据 account['auth_strategy'] 分派到对应 helper。"""
|
||||
strat = (account.get("auth_strategy") or "").strip()
|
||||
|
||||
if not strat:
|
||||
raise AuthStrategyError(
|
||||
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
|
||||
"account has no auth_strategy set",
|
||||
)
|
||||
|
||||
def _need_password() -> str:
|
||||
if not credentials or not credentials.get("plaintext"):
|
||||
raise AuthStrategyError(
|
||||
"ERR_CREDENTIAL_MISSING",
|
||||
f"auth_strategy={strat!r} requires credentials.plaintext",
|
||||
)
|
||||
return str(credentials["plaintext"])
|
||||
|
||||
if strat == "password_auto":
|
||||
return ensure_logged_in_password_auto(page, account, _need_password(), **kwargs)
|
||||
if strat == "password_with_captcha":
|
||||
return ensure_logged_in_password_with_captcha(page, account, _need_password(), **kwargs)
|
||||
if strat == "password_plus_2fa":
|
||||
return ensure_logged_in_password_plus_2fa(page, account, _need_password(), **kwargs)
|
||||
if strat == "qr_code_manual":
|
||||
return ensure_logged_in_qr_code_manual(page, account, **kwargs)
|
||||
if strat == "per_session_manual":
|
||||
return ensure_logged_in_per_session_manual(page, account, **kwargs)
|
||||
if strat == "device_bound":
|
||||
password = None
|
||||
if credentials and credentials.get("plaintext"):
|
||||
password = str(credentials["plaintext"])
|
||||
return ensure_logged_in_device_bound(page, account, password, **kwargs)
|
||||
if strat == "client_certificate":
|
||||
return ensure_logged_in_client_certificate(page, account)
|
||||
if strat == "api_token":
|
||||
return ensure_logged_in_api_token(page, account)
|
||||
if strat == "sso_redirect":
|
||||
return ensure_logged_in_sso_redirect(page, account, **kwargs)
|
||||
|
||||
raise AuthStrategyError(
|
||||
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
|
||||
f"unknown auth_strategy: {strat!r}",
|
||||
)
|
||||
74
scripts/service/rpa_helpers/browser.py
Normal file
74
scripts/service/rpa_helpers/browser.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unified Chromium launch with stealth + persistent profile for RPA helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from service.browser_service import resolve_chromium_channel
|
||||
from util.playwright_stealth import (
|
||||
STEALTH_INIT_SCRIPT,
|
||||
persistent_context_launch_parts,
|
||||
stealth_enabled,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.sync_api import BrowserContext, Page
|
||||
|
||||
|
||||
def launch_browser_with_profile(
|
||||
profile_dir: str,
|
||||
*,
|
||||
channel: str | None = None,
|
||||
headless: bool = False,
|
||||
extra_args: list[str] | None = None,
|
||||
locale: str = "zh-CN",
|
||||
) -> tuple["BrowserContext", "Page"]:
|
||||
"""启动浏览器(Chrome 优先 / Edge fallback),返回 (context, page)。
|
||||
|
||||
调用方应在 finally 中调用 close_browser(context)。
|
||||
"""
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
|
||||
resolved_channel = channel if channel is not None else resolve_chromium_channel()
|
||||
if not resolved_channel:
|
||||
raise RuntimeError("no chrome/edge found on system")
|
||||
|
||||
_pw = sync_playwright().start()
|
||||
try:
|
||||
args, ignore_automation = persistent_context_launch_parts(extra_args=extra_args)
|
||||
launch_kwargs: dict = dict(
|
||||
user_data_dir=profile_dir,
|
||||
headless=headless,
|
||||
channel=resolved_channel,
|
||||
no_viewport=True,
|
||||
locale=locale,
|
||||
args=args,
|
||||
)
|
||||
if ignore_automation is not None:
|
||||
launch_kwargs["ignore_default_args"] = ignore_automation
|
||||
ctx = _pw.chromium.launch_persistent_context(**launch_kwargs)
|
||||
if stealth_enabled():
|
||||
ctx.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||
setattr(ctx, "_openclaw_pw", _pw)
|
||||
return ctx, page
|
||||
except Exception:
|
||||
_pw.stop()
|
||||
raise
|
||||
|
||||
|
||||
def close_browser(ctx) -> None:
|
||||
"""关闭 context 并清理 Playwright manager。"""
|
||||
pw = getattr(ctx, "_openclaw_pw", None)
|
||||
try:
|
||||
ctx.close()
|
||||
except Exception:
|
||||
pass
|
||||
if pw is not None:
|
||||
try:
|
||||
pw.stop()
|
||||
except Exception:
|
||||
pass
|
||||
57
scripts/service/rpa_helpers/human.py
Normal file
57
scripts/service/rpa_helpers/human.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Human-like interaction primitives for Playwright locators (no logging)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import time
|
||||
|
||||
|
||||
def pause(min_ms: int = 200, max_ms: int = 800) -> None:
|
||||
"""随机停顿 min_ms ~ max_ms 毫秒。"""
|
||||
lo = min(min_ms, max_ms) / 1000.0
|
||||
hi = max(min_ms, max_ms) / 1000.0
|
||||
time.sleep(random.uniform(lo, hi))
|
||||
|
||||
|
||||
def human_type(
|
||||
locator,
|
||||
text: str,
|
||||
*,
|
||||
per_char_ms_range: tuple[int, int] = (50, 200),
|
||||
pre_pause: bool = True,
|
||||
) -> None:
|
||||
"""逐字符输入(locator.type + delay);locator 为 Playwright Locator。"""
|
||||
locator.scroll_into_view_if_needed()
|
||||
if pre_pause:
|
||||
pause()
|
||||
locator.click()
|
||||
pause()
|
||||
lo, hi = per_char_ms_range
|
||||
delay_ms = random.uniform(float(lo), float(hi))
|
||||
locator.type(text, delay=int(delay_ms))
|
||||
|
||||
|
||||
def human_click(locator) -> None:
|
||||
"""人类化点击:scroll_into_view → pause → click → pause"""
|
||||
locator.scroll_into_view_if_needed()
|
||||
pause()
|
||||
locator.click()
|
||||
pause()
|
||||
|
||||
|
||||
def human_select(locator, value_or_label: str) -> None:
|
||||
"""人类化下拉选择:scroll_into_view → pause → click → pause → select_option"""
|
||||
locator.scroll_into_view_if_needed()
|
||||
pause()
|
||||
locator.click()
|
||||
pause()
|
||||
try:
|
||||
locator.select_option(value=value_or_label)
|
||||
except Exception:
|
||||
locator.select_option(label=value_or_label)
|
||||
|
||||
|
||||
def human_scroll_to(locator) -> None:
|
||||
"""滚动到元素并稍作停顿。"""
|
||||
locator.scroll_into_view_if_needed()
|
||||
pause()
|
||||
323
scripts/service/secret_store.py
Normal file
323
scripts/service/secret_store.py
Normal 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,4 +1,4 @@
|
||||
"""技能级常量与本地 CLI 调试环境注入(仅 CLI 入口显式调用 _apply_cli_local_dev_env 时生效)。"""
|
||||
"""技能级常量。本地 CLI 默认值见 jiangchang_skill_core.runtime_env(main.py 最早调用 apply_cli_local_defaults)。"""
|
||||
import os
|
||||
|
||||
# scripts/util/constants.py -> skill root = parents[2]
|
||||
@@ -7,29 +7,3 @@ BASE_DIR = _BASE
|
||||
SKILL_SLUG = "account-manager"
|
||||
# 与其它 OpenClaw 技能对齐:openclaw.skill.<slug_下划线>
|
||||
LOG_LOGGER_NAME = "openclaw.skill.account_manager"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 本地 CLI 调试:直接运行 python main.py 时,若未设置 JIANGCHANG_*,可自动注入下面默认值。
|
||||
# - 仅当 CLI 里调用了 _apply_cli_local_dev_env() 时生效;被其它脚本 import 不会改 os.environ。
|
||||
# - 不会覆盖宿主/终端已设置的 JIANGCHANG_DATA_ROOT、JIANGCHANG_USER_ID。
|
||||
# - 开启方式(二选一):
|
||||
# 1) 将 _ACCOUNT_MANAGER_CLI_LOCAL_DEV 改为 True;
|
||||
# 2) 或设置环境变量 JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV=1(无需改代码)。
|
||||
# 合并/发版前请关闭开关,避免把个人机器路径带进生产制品。
|
||||
# ---------------------------------------------------------------------------
|
||||
_ACCOUNT_MANAGER_CLI_LOCAL_DEV = True
|
||||
_ACCOUNT_MANAGER_CLI_LOCAL_DATA_ROOT = r"D:\jiangchang-data"
|
||||
_ACCOUNT_MANAGER_CLI_LOCAL_USER_ID = "10032"
|
||||
|
||||
|
||||
def _apply_cli_local_dev_env() -> None:
|
||||
enabled = _ACCOUNT_MANAGER_CLI_LOCAL_DEV
|
||||
if not enabled:
|
||||
v = (os.getenv("JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV") or "").strip().lower()
|
||||
enabled = v in ("1", "true", "yes", "on")
|
||||
if not enabled:
|
||||
return
|
||||
if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = _ACCOUNT_MANAGER_CLI_LOCAL_DATA_ROOT.strip()
|
||||
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
|
||||
os.environ["JIANGCHANG_USER_ID"] = _ACCOUNT_MANAGER_CLI_LOCAL_USER_ID.strip()
|
||||
|
||||
@@ -1,69 +1,21 @@
|
||||
"""技能级文件日志。"""
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
"""Re-export unified logging (implementation: jiangchang_skill_core.unified_logging)."""
|
||||
|
||||
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
|
||||
from util.runtime_paths import get_skill_logs_dir
|
||||
from jiangchang_skill_core.unified_logging import (
|
||||
attach_unified_file_handler,
|
||||
ensure_trace_for_process,
|
||||
get_skill_log_file_path,
|
||||
get_skill_logger,
|
||||
get_unified_logs_dir,
|
||||
setup_skill_logging,
|
||||
subprocess_env_with_trace,
|
||||
)
|
||||
|
||||
|
||||
def _skill_log_file_path() -> str:
|
||||
"""主日志文件路径;可由 JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE 覆盖为绝对路径。"""
|
||||
override = (os.getenv("JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE") or "").strip()
|
||||
if override:
|
||||
parent = os.path.dirname(os.path.abspath(override))
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
return os.path.abspath(override)
|
||||
return os.path.join(get_skill_logs_dir(), f"{SKILL_SLUG}.log")
|
||||
|
||||
|
||||
def _log_level_from_env() -> int:
|
||||
v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper()
|
||||
return getattr(logging, v, None) or logging.INFO
|
||||
|
||||
|
||||
def get_skill_log_file_path() -> str:
|
||||
"""主日志文件绝对路径(与 get_skill_logger 写入文件一致)。"""
|
||||
return _skill_log_file_path()
|
||||
|
||||
|
||||
def get_skill_logger() -> logging.Logger:
|
||||
"""
|
||||
技能级日志:单文件按日切分(TimedRotatingFileHandler midnight),UTF-8。
|
||||
环境变量:JIANGCHANG_LOG_LEVEL(默认 INFO)、JIANGCHANG_LOG_TO_STDERR=1 时 WARNING+ 同步 stderr、
|
||||
JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE 覆盖日志文件路径。
|
||||
"""
|
||||
log = logging.getLogger(LOG_LOGGER_NAME)
|
||||
if log.handlers:
|
||||
return log
|
||||
log.setLevel(_log_level_from_env())
|
||||
path = _skill_log_file_path()
|
||||
fh = TimedRotatingFileHandler(
|
||||
path,
|
||||
when="midnight",
|
||||
interval=1,
|
||||
backupCount=30,
|
||||
encoding="utf-8",
|
||||
delay=True,
|
||||
)
|
||||
fh.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||
)
|
||||
)
|
||||
log.addHandler(fh)
|
||||
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
):
|
||||
sh = logging.StreamHandler(sys.stderr)
|
||||
sh.setLevel(logging.WARNING)
|
||||
sh.setFormatter(fh.formatter)
|
||||
log.addHandler(sh)
|
||||
log.propagate = False
|
||||
return log
|
||||
__all__ = [
|
||||
"attach_unified_file_handler",
|
||||
"ensure_trace_for_process",
|
||||
"get_skill_log_file_path",
|
||||
"get_skill_logger",
|
||||
"get_unified_logs_dir",
|
||||
"setup_skill_logging",
|
||||
"subprocess_env_with_trace",
|
||||
]
|
||||
|
||||
@@ -1,139 +1,581 @@
|
||||
"""平台配置、别名解析、手机号校验。"""
|
||||
import re
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Platform definitions and resolution.
|
||||
|
||||
# 平台唯一配置表:只改这里即可。键 = 入库的 platform;label = 帮助/提示里的展示名;
|
||||
# prefix = 默认账号名「{prefix}{序号}号」,省略时等于 label;
|
||||
# aliases = 额外 CLI 称呼(英文键与 label 已自动参与解析,不必重复写)。
|
||||
# 登录检测:仅看页面 DOM。匹配 anchor 的标签页上出现「未登录」选择器则未登录;否则视为已登录。
|
||||
# - _LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS:通用「登录」按钮/链接等(可被 login_skip_generic_logged_out_dom 关闭)。
|
||||
# - login_logged_out_selectors:按平台追加;误判时也可用 login_skip_generic_logged_out_dom + 仅平台自选。
|
||||
PLATFORMS = {
|
||||
"sohu": {
|
||||
"url": "https://mp.sohu.com",
|
||||
"label": "搜狐号",
|
||||
"prefix": "搜狐",
|
||||
"aliases": ["搜狐"],
|
||||
Central authoritative registry for all platforms (logistics, LLM, content, etc.).
|
||||
Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms().
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from util.logging_config import get_skill_logger
|
||||
|
||||
PLATFORM_KEY_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{1,63}$")
|
||||
|
||||
DEFAULT_CAPABILITIES_JSON = '{"web": true, "api_key": false, "rpa": true}'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform specification fields:
|
||||
# display_name – Human-readable name (e.g. "Maersk")
|
||||
# domain – Business domain: logistics / llm / content / ecommerce / generic
|
||||
# provider_code – Supplier code (logistics); can be same as key
|
||||
# default_url – Default entry URL (empty string if none)
|
||||
# aliases – List of name aliases (Chinese/English, for CLI resolution)
|
||||
# capabilities – Dict of capability flags:
|
||||
# {"web": bool, "api_key": bool, "rpa": bool}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PLATFORMS: dict[str, dict[str, Any]] = {
|
||||
# =========================================================================
|
||||
# Logistics platforms
|
||||
# =========================================================================
|
||||
"maersk": {
|
||||
"display_name": "Maersk",
|
||||
"domain": "logistics",
|
||||
"provider_code": "maersk",
|
||||
"default_url": "https://www.maersk.com",
|
||||
"aliases": ["马士基", "maersk", "Maersk"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"toutiao": {
|
||||
"url": "https://mp.toutiao.com/",
|
||||
"label": "头条号",
|
||||
"prefix": "头条",
|
||||
"aliases": ["头条"],
|
||||
"cosco": {
|
||||
"display_name": "COSCO",
|
||||
"domain": "logistics",
|
||||
"provider_code": "cosco",
|
||||
"default_url": "",
|
||||
"aliases": ["中远海运", "COSCO", "cosco"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"zhihu": {
|
||||
"url": "https://www.zhihu.com",
|
||||
"label": "知乎",
|
||||
"aliases": ["知乎号"],
|
||||
"msc": {
|
||||
"display_name": "MSC",
|
||||
"domain": "logistics",
|
||||
"provider_code": "msc",
|
||||
"default_url": "",
|
||||
"aliases": ["MSC", "地中海航运"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"wechat": {
|
||||
"url": "https://mp.weixin.qq.com",
|
||||
"label": "微信公众号",
|
||||
"prefix": "微信",
|
||||
"aliases": ["公众号", "微信"],
|
||||
"cma_cgm": {
|
||||
"display_name": "CMA CGM",
|
||||
"domain": "logistics",
|
||||
"provider_code": "cma_cgm",
|
||||
"default_url": "",
|
||||
"aliases": ["达飞", "CMA CGM", "cma cgm"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"kimi": {
|
||||
"url": "https://kimi.moonshot.cn",
|
||||
"label": "Kimi",
|
||||
"aliases": ["月之暗面"],
|
||||
"evergreen": {
|
||||
"display_name": "Evergreen",
|
||||
"domain": "logistics",
|
||||
"provider_code": "evergreen",
|
||||
"default_url": "",
|
||||
"aliases": ["长荣", "Evergreen", "evergreen"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"cargo_wise": {
|
||||
"display_name": "CargoWise",
|
||||
"domain": "logistics",
|
||||
"provider_code": "cargo_wise",
|
||||
"default_url": "",
|
||||
"aliases": ["CargoWise", "Cargo Wise", "cargo wise"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"freightos": {
|
||||
"display_name": "Freightos",
|
||||
"domain": "logistics",
|
||||
"provider_code": "freightos",
|
||||
"default_url": "",
|
||||
"aliases": ["Freightos", "freightos"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"webcargo": {
|
||||
"display_name": "WebCargo",
|
||||
"domain": "logistics",
|
||||
"provider_code": "webcargo",
|
||||
"default_url": "",
|
||||
"aliases": ["WebCargo", "web cargo"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"sinotrans": {
|
||||
"display_name": "Sinotrans",
|
||||
"domain": "logistics",
|
||||
"provider_code": "sinotrans",
|
||||
"default_url": "",
|
||||
"aliases": ["中外运", "Sinotrans", "sinotrans"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
# =========================================================================
|
||||
# LLM / AI platforms
|
||||
# =========================================================================
|
||||
"deepseek": {
|
||||
"url": "https://chat.deepseek.com",
|
||||
"label": "DeepSeek",
|
||||
"display_name": "DeepSeek",
|
||||
"domain": "llm",
|
||||
"provider_code": "deepseek",
|
||||
"default_url": "https://chat.deepseek.com",
|
||||
"aliases": ["DeepSeek", "deepseek"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"doubao": {
|
||||
"url": "https://www.doubao.com",
|
||||
"label": "豆包",
|
||||
"display_name": "Doubao",
|
||||
"domain": "llm",
|
||||
"provider_code": "doubao",
|
||||
"default_url": "https://www.doubao.com",
|
||||
"aliases": ["豆包", "Doubao"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"kimi": {
|
||||
"display_name": "Kimi",
|
||||
"domain": "llm",
|
||||
"provider_code": "kimi",
|
||||
"default_url": "https://kimi.moonshot.cn",
|
||||
"aliases": ["Kimi", "月之暗面"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"qianwen": {
|
||||
"url": "https://tongyi.aliyun.com",
|
||||
"label": "通义千问",
|
||||
"prefix": "通义",
|
||||
"aliases": ["通义", "千问"],
|
||||
"display_name": "Qianwen",
|
||||
"domain": "llm",
|
||||
"provider_code": "qianwen",
|
||||
"default_url": "https://tongyi.aliyun.com",
|
||||
"aliases": ["通义千问", "通义", "千问", "Qianwen"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"yiyan": {
|
||||
"url": "https://yiyan.baidu.com",
|
||||
"label": "文心一言",
|
||||
"prefix": "文心",
|
||||
"aliases": ["文心", "一言"],
|
||||
"display_name": "YiYan",
|
||||
"domain": "llm",
|
||||
"provider_code": "yiyan",
|
||||
"default_url": "https://yiyan.baidu.com",
|
||||
"aliases": ["文心一言", "文心", "一言", "YiYan"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"yuanbao": {
|
||||
"url": "https://yuanbao.tencent.com",
|
||||
"label": "腾讯元宝",
|
||||
"prefix": "元宝",
|
||||
"aliases": ["元宝"],
|
||||
"display_name": "Yuanbao",
|
||||
"domain": "llm",
|
||||
"provider_code": "yuanbao",
|
||||
"default_url": "https://yuanbao.tencent.com",
|
||||
"aliases": ["腾讯元宝", "元宝", "Yuanbao"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"gemini": {
|
||||
"display_name": "Gemini",
|
||||
"domain": "llm",
|
||||
"provider_code": "gemini",
|
||||
"default_url": "https://gemini.google.com",
|
||||
"aliases": ["Gemini", "谷歌Gemini", "Google Gemini", "Bard"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"minimax": {
|
||||
"display_name": "MiniMax",
|
||||
"domain": "llm",
|
||||
"provider_code": "minimax",
|
||||
"default_url": "",
|
||||
"aliases": ["MiniMax", "minimax"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
# =========================================================================
|
||||
# Content platforms (carried forward from v1)
|
||||
# =========================================================================
|
||||
"sohu": {
|
||||
"display_name": "搜狐号",
|
||||
"domain": "content",
|
||||
"provider_code": "sohu",
|
||||
"default_url": "https://mp.sohu.com",
|
||||
"aliases": ["搜狐", "搜狐号"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"toutiao": {
|
||||
"display_name": "头条号",
|
||||
"domain": "content",
|
||||
"provider_code": "toutiao",
|
||||
"default_url": "https://mp.toutiao.com/",
|
||||
"aliases": ["头条", "头条号"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"zhihu": {
|
||||
"display_name": "知乎",
|
||||
"domain": "content",
|
||||
"provider_code": "zhihu",
|
||||
"default_url": "https://www.zhihu.com",
|
||||
"aliases": ["知乎", "知乎号"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"wechat": {
|
||||
"display_name": "微信公众号",
|
||||
"domain": "content",
|
||||
"provider_code": "wechat",
|
||||
"default_url": "https://mp.weixin.qq.com",
|
||||
"aliases": ["公众号", "微信", "微信公众号"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
# =========================================================================
|
||||
# Aligned with db.platform_defaults.PLATFORM_DEFAULT_AUTH_STRATEGY keys
|
||||
# =========================================================================
|
||||
"douyin": {
|
||||
"display_name": "抖音",
|
||||
"domain": "content",
|
||||
"provider_code": "douyin",
|
||||
"default_url": "https://www.douyin.com",
|
||||
"aliases": ["抖音", "Douyin"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"wechat_mp": {
|
||||
"display_name": "微信公众号",
|
||||
"domain": "content",
|
||||
"provider_code": "wechat_mp",
|
||||
"default_url": "https://mp.weixin.qq.com",
|
||||
"aliases": ["微信公众号", "公众号", "WeChat MP"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"xiaohongshu": {
|
||||
"display_name": "小红书",
|
||||
"domain": "content",
|
||||
"provider_code": "xiaohongshu",
|
||||
"default_url": "https://www.xiaohongshu.com",
|
||||
"aliases": ["小红书", "Xiaohongshu", "RED"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"bilibili": {
|
||||
"display_name": "哔哩哔哩",
|
||||
"domain": "content",
|
||||
"provider_code": "bilibili",
|
||||
"default_url": "https://www.bilibili.com",
|
||||
"aliases": ["B站", "哔哩哔哩", "Bilibili"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"github": {
|
||||
"display_name": "GitHub",
|
||||
"domain": "dev",
|
||||
"provider_code": "github",
|
||||
"default_url": "https://github.com/login",
|
||||
"aliases": ["GitHub", "github"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"aws": {
|
||||
"display_name": "AWS",
|
||||
"domain": "dev",
|
||||
"provider_code": "aws",
|
||||
"default_url": "https://console.aws.amazon.com",
|
||||
"aliases": ["AWS", "Amazon Web Services", "亚马逊云"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"gcp": {
|
||||
"display_name": "GCP",
|
||||
"domain": "dev",
|
||||
"provider_code": "gcp",
|
||||
"default_url": "https://console.cloud.google.com",
|
||||
"aliases": ["GCP", "Google Cloud", "谷歌云"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"icbc_sim": {
|
||||
"display_name": "工行仿真",
|
||||
"domain": "sim",
|
||||
"provider_code": "icbc_sim",
|
||||
"default_url": "http://localhost:5180",
|
||||
"aliases": ["工行仿真", "ICBC Simulator", "ICBC SIM"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"openai": {
|
||||
"display_name": "OpenAI API",
|
||||
"domain": "llm",
|
||||
"provider_code": "openai",
|
||||
"default_url": "https://platform.openai.com",
|
||||
"aliases": ["OpenAI", "openai", "GPT", "ChatGPT API"],
|
||||
"capabilities": {"web": False, "api_key": True, "rpa": False},
|
||||
},
|
||||
"anthropic": {
|
||||
"display_name": "Anthropic",
|
||||
"domain": "llm",
|
||||
"provider_code": "anthropic",
|
||||
"default_url": "https://console.anthropic.com",
|
||||
"aliases": ["Anthropic", "anthropic", "Claude API"],
|
||||
"capabilities": {"web": False, "api_key": True, "rpa": False},
|
||||
},
|
||||
}
|
||||
|
||||
# 未登录页共性:主行动点含「登录」——submit 按钮、Element 主按钮、Semi 文案、标题、通栏主按钮等,Playwright :has-text 可覆盖多数站点。
|
||||
_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS = (
|
||||
'button:has-text("登录")',
|
||||
'role=button[name="登录"]',
|
||||
'a:has-text("登录")',
|
||||
'h4:has-text("登录")',
|
||||
'span.semi-button-content:has-text("登录")',
|
||||
)
|
||||
|
||||
|
||||
def _build_platform_derived():
|
||||
urls = {}
|
||||
name_prefix = {}
|
||||
primary_cn = {}
|
||||
alias_to_key = {}
|
||||
|
||||
def _register_alias(alias: str, key: str) -> None:
|
||||
if not alias or not str(alias).strip():
|
||||
return
|
||||
a = str(alias).strip()
|
||||
if a not in alias_to_key:
|
||||
alias_to_key[a] = key
|
||||
lo = a.lower()
|
||||
if lo not in alias_to_key:
|
||||
alias_to_key[lo] = key
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derived alias map
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_alias_map() -> dict[str, str]:
|
||||
"""Build a case-insensitive flat alias -> key map."""
|
||||
m: dict[str, str] = {}
|
||||
for key, spec in PLATFORMS.items():
|
||||
urls[key] = spec["url"]
|
||||
primary_cn[key] = spec["label"]
|
||||
name_prefix[key] = (spec.get("prefix") or spec["label"]).strip() or key
|
||||
_register_alias(key, key)
|
||||
_register_alias(spec["label"], key)
|
||||
for a in spec.get("aliases") or []:
|
||||
_register_alias(a, key)
|
||||
|
||||
return urls, name_prefix, primary_cn, alias_to_key
|
||||
# Register the key itself (lowercase)
|
||||
m[key.lower()] = key
|
||||
# Register all aliases (original + lowercase)
|
||||
for alias in [spec["display_name"]] + spec.get("aliases", []):
|
||||
a = str(alias).strip()
|
||||
if a:
|
||||
m[a] = key
|
||||
m[a.lower()] = key
|
||||
return m
|
||||
|
||||
|
||||
PLATFORM_URLS, _PLATFORM_NAME_CN, _PLATFORM_PRIMARY_CN, _PLATFORM_ALIAS_TO_KEY = _build_platform_derived()
|
||||
_PLATFORM_ALIAS_MAP = _build_alias_map()
|
||||
|
||||
|
||||
def resolve_platform_key(name: str):
|
||||
"""将用户输入解析为内部 platform 键;无法识别返回 None。"""
|
||||
if name is None:
|
||||
def resolve_platform_key(name: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve user input to a canonical platform key (built-in PLATFORMS only).
|
||||
Supports: key, display_name, any alias. Case-insensitive.
|
||||
Returns None if unrecognized.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
s = str(name).strip()
|
||||
if not s:
|
||||
return None
|
||||
sl = s.lower()
|
||||
if sl in PLATFORM_URLS:
|
||||
# Direct key match
|
||||
if sl in PLATFORMS:
|
||||
return sl
|
||||
return _PLATFORM_ALIAS_TO_KEY.get(s)
|
||||
# Alias map
|
||||
if sl in _PLATFORM_ALIAS_MAP:
|
||||
return _PLATFORM_ALIAS_MAP[sl]
|
||||
if s in _PLATFORM_ALIAS_MAP:
|
||||
return _PLATFORM_ALIAS_MAP[s]
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_phone_digits(phone: str) -> str:
|
||||
"""从输入中提取数字串,供号段规则校验与入库去重(不对外承诺可随意夹杂符号)。"""
|
||||
d = re.sub(r"\D", "", (phone or "").strip())
|
||||
if d.startswith("86") and len(d) >= 13:
|
||||
d = d[2:]
|
||||
return d
|
||||
def is_valid_platform_key_format(name: str) -> bool:
|
||||
"""平台 key 格式:^[a-z0-9][a-z0-9_-]{1,63}$"""
|
||||
s = (name or "").strip().lower()
|
||||
if not s:
|
||||
return False
|
||||
return bool(PLATFORM_KEY_PATTERN.match(s))
|
||||
|
||||
|
||||
def _is_valid_cn_mobile11(digits: str) -> bool:
|
||||
"""中国大陆 11 位手机号:1 开头,第二位 3–9,共 11 位数字。"""
|
||||
return bool(re.fullmatch(r"1[3-9]\d{9}", digits or ""))
|
||||
def _parse_aliases_json(val: Any) -> list[str]:
|
||||
if val is None:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(val) if isinstance(val, str) else val
|
||||
if isinstance(data, list):
|
||||
return [str(a).strip() for a in data if str(a).strip()]
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlatformResolveResult:
|
||||
platform_key: Optional[str] = None
|
||||
error_code: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return bool(self.platform_key) and not self.error_code
|
||||
|
||||
|
||||
def _resolve_platform_from_db(conn, raw: str) -> PlatformResolveResult:
|
||||
"""在 platforms 表中解析:精确 key(enabled=1)与 aliases_json。"""
|
||||
raw_stripped = (raw or "").strip()
|
||||
raw_lower = raw_stripped.lower()
|
||||
|
||||
if is_valid_platform_key_format(raw_stripped):
|
||||
row = conn.execute(
|
||||
"SELECT platform_key, enabled FROM platforms WHERE platform_key = ?",
|
||||
(raw_lower,),
|
||||
).fetchone()
|
||||
if row:
|
||||
if not row[1]:
|
||||
return PlatformResolveResult(
|
||||
error_code="PLATFORM_DISABLED",
|
||||
message=f"平台 {row[0]} 已禁用。",
|
||||
)
|
||||
return PlatformResolveResult(platform_key=row[0])
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT platform_key, enabled, aliases_json FROM platforms"
|
||||
).fetchall()
|
||||
for platform_key, enabled, aliases_json in rows:
|
||||
if not enabled:
|
||||
continue
|
||||
names = {platform_key.lower()}
|
||||
for alias in _parse_aliases_json(aliases_json):
|
||||
names.add(alias.lower())
|
||||
names.add(alias)
|
||||
if raw_lower in names or raw_stripped in names:
|
||||
return PlatformResolveResult(platform_key=platform_key)
|
||||
|
||||
return PlatformResolveResult()
|
||||
|
||||
|
||||
def resolve_platform_key_dynamic(input_name: str, conn=None) -> PlatformResolveResult:
|
||||
"""
|
||||
统一平台解析:内置 PLATFORMS → DB platforms 表 → 错误分类。
|
||||
"""
|
||||
raw = (input_name or "").strip()
|
||||
if not raw:
|
||||
return PlatformResolveResult(
|
||||
error_code="INVALID_PLATFORM",
|
||||
message="平台名不能为空。",
|
||||
)
|
||||
|
||||
builtin = resolve_platform_key(raw)
|
||||
if builtin:
|
||||
return PlatformResolveResult(platform_key=builtin)
|
||||
|
||||
close_conn = False
|
||||
if conn is None:
|
||||
from db.connection import get_conn, init_db
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
close_conn = True
|
||||
try:
|
||||
db_result = _resolve_platform_from_db(conn, raw)
|
||||
if db_result.ok or db_result.error_code:
|
||||
return db_result
|
||||
finally:
|
||||
if close_conn:
|
||||
conn.close()
|
||||
|
||||
if not is_valid_platform_key_format(raw):
|
||||
return PlatformResolveResult(
|
||||
error_code="INVALID_PLATFORM_KEY",
|
||||
message=(
|
||||
f"平台 key 格式非法:「{raw}」。"
|
||||
"建议使用小写字母、数字、下划线或连字符,例如 jiangchang。"
|
||||
),
|
||||
)
|
||||
|
||||
key_hint = raw.lower()
|
||||
return PlatformResolveResult(
|
||||
error_code="PLATFORM_NOT_REGISTERED",
|
||||
message=(
|
||||
f"平台 {key_hint} 尚未注册,请先执行 "
|
||||
f"account platform ensure --key {key_hint} --display-name <名称> [--url <url>]"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_platform_spec(platform_key: str, conn=None) -> dict[str, Any]:
|
||||
"""内置 PLATFORMS 优先结构;自定义平台从 DB platforms 表读取。"""
|
||||
spec = PLATFORMS.get(platform_key)
|
||||
if spec:
|
||||
return dict(spec)
|
||||
|
||||
close_conn = False
|
||||
if conn is None:
|
||||
from db.connection import get_conn, init_db
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
close_conn = True
|
||||
try:
|
||||
from db.accounts_repo import get_platform_from_db
|
||||
|
||||
plat = get_platform_from_db(conn, platform_key)
|
||||
if plat:
|
||||
return {
|
||||
"display_name": plat["display_name"],
|
||||
"domain": plat.get("domain") or "generic",
|
||||
"provider_code": plat.get("provider_code") or platform_key,
|
||||
"default_url": plat.get("default_url") or "",
|
||||
"aliases": plat.get("aliases") or [],
|
||||
"capabilities": plat.get("capabilities") or {},
|
||||
}
|
||||
finally:
|
||||
if close_conn:
|
||||
conn.close()
|
||||
return {}
|
||||
|
||||
|
||||
def get_platform(key: str) -> Optional[dict[str, Any]]:
|
||||
"""Return the full platform spec dict for a validated built-in key."""
|
||||
return PLATFORMS.get(key)
|
||||
|
||||
|
||||
def list_platforms(domain: Optional[str] = None) -> list[dict[str, Any]]:
|
||||
"""List all platforms, optionally filtered by domain.
|
||||
Returns a list of dicts with metadata."""
|
||||
result = []
|
||||
for key, spec in PLATFORMS.items():
|
||||
if domain and spec["domain"] != domain:
|
||||
continue
|
||||
result.append({
|
||||
"platform_key": key,
|
||||
"display_name": spec["display_name"],
|
||||
"domain": spec["domain"],
|
||||
"provider_code": spec.get("provider_code"),
|
||||
"default_url": spec.get("default_url", ""),
|
||||
"aliases": spec.get("aliases", []),
|
||||
"capabilities": spec.get("capabilities", {}),
|
||||
"enabled": True,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def seed_platforms(conn) -> None:
|
||||
"""
|
||||
Upsert all platform definitions into the 'platforms' table.
|
||||
Must be called after init_db() in the same connection lifecycle.
|
||||
"""
|
||||
from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY
|
||||
|
||||
log = get_skill_logger()
|
||||
log.info("platform_seed_start")
|
||||
cur = conn.cursor()
|
||||
now = int(time.time())
|
||||
upsert_count = 0
|
||||
for key, spec in PLATFORMS.items():
|
||||
aliases_json = json.dumps(spec.get("aliases", []), ensure_ascii=False)
|
||||
capabilities_json = json.dumps(spec.get("capabilities", {}), ensure_ascii=False)
|
||||
default_auth_strategy = PLATFORM_DEFAULT_AUTH_STRATEGY.get(key)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
|
||||
default_url, aliases_json, capabilities_json,
|
||||
enabled, default_auth_strategy, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
|
||||
ON CONFLICT(platform_key) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
domain = excluded.domain,
|
||||
provider_code = excluded.provider_code,
|
||||
default_url = excluded.default_url,
|
||||
aliases_json = excluded.aliases_json,
|
||||
capabilities_json = excluded.capabilities_json,
|
||||
enabled = 1,
|
||||
default_auth_strategy = excluded.default_auth_strategy,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
key,
|
||||
spec["display_name"],
|
||||
spec["domain"],
|
||||
spec.get("provider_code"),
|
||||
spec.get("default_url", ""),
|
||||
aliases_json,
|
||||
capabilities_json,
|
||||
default_auth_strategy,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
upsert_count += 1
|
||||
conn.commit()
|
||||
log.info("platform_seed_done count=%s", upsert_count)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy compatibility helpers (for old account_service v1 code references)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Kept for backward compatibility so old imports don't break immediately.
|
||||
# New code should use PLATFORMS dict directly and resolve_platform_key().
|
||||
_PLATFORM_PRIMARY_CN: dict[str, str] = {
|
||||
k: spec["display_name"] for k, spec in PLATFORMS.items()
|
||||
}
|
||||
PLATFORM_URLS: dict[str, str] = {
|
||||
k: (spec.get("default_url") or "") for k, spec in PLATFORMS.items()
|
||||
}
|
||||
|
||||
|
||||
def _platform_list_cn_for_help() -> str:
|
||||
"""帮助文案:一行中文展示名(不重复内部键)。"""
|
||||
parts = []
|
||||
for k in sorted(PLATFORM_URLS.keys()):
|
||||
parts.append(_PLATFORM_PRIMARY_CN.get(k, k))
|
||||
return "、".join(parts)
|
||||
"""帮助文案:一行展示名,供 CLI 用法提示。"""
|
||||
return "、".join(
|
||||
spec["display_name"] for _k, spec in sorted(
|
||||
PLATFORMS.items(), key=lambda x: x[1]["display_name"]
|
||||
)
|
||||
)
|
||||
|
||||
40
scripts/util/playwright_stealth.py
Normal file
40
scripts/util/playwright_stealth.py
Normal 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"]
|
||||
84
scripts/util/profile_shortcut.py
Normal file
84
scripts/util/profile_shortcut.py
Normal 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
|
||||
@@ -1,46 +1,28 @@
|
||||
"""数据目录、profile 路径、CLI 路径调试输出。"""
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Data directory, profile path, CLI path debugging output.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from jiangchang_skill_core.runtime_env import get_data_root, get_user_id
|
||||
from util.constants import SKILL_SLUG
|
||||
from util.platforms import _PLATFORM_PRIMARY_CN
|
||||
|
||||
|
||||
def get_data_root():
|
||||
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||
if env:
|
||||
return env
|
||||
if sys.platform == "win32":
|
||||
return r"D:\jiangchang-data"
|
||||
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
|
||||
|
||||
|
||||
def get_user_id():
|
||||
uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip()
|
||||
return uid or "_anon"
|
||||
|
||||
|
||||
def get_skill_data_dir():
|
||||
def get_skill_data_dir() -> str:
|
||||
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_skill_logs_dir() -> str:
|
||||
"""{DATA_ROOT}/{USER_ID}/account-manager/logs/"""
|
||||
path = os.path.join(get_skill_data_dir(), "logs")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_db_path():
|
||||
def get_db_path() -> str:
|
||||
return os.path.join(get_skill_data_dir(), "account-manager.db")
|
||||
|
||||
|
||||
def _runtime_paths_debug_text():
|
||||
"""供排查「为何 list 没数据」:终端未注入环境变量时会落到 _anon 等默认目录,与网关里用的库不是同一个文件。"""
|
||||
def _runtime_paths_debug_text() -> str:
|
||||
"""For debugging: print current data path info to stderr."""
|
||||
env_root = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() or "(未设置)"
|
||||
env_uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "(未设置→使用 _anon)"
|
||||
return (
|
||||
@@ -53,8 +35,8 @@ def _runtime_paths_debug_text():
|
||||
)
|
||||
|
||||
|
||||
def _maybe_print_paths_debug_cli():
|
||||
"""设置 JIANGCHANG_ACCOUNT_DEBUG_PATHS=1 时,每次执行子命令前在 stderr 打印路径。"""
|
||||
def _maybe_print_paths_debug_cli() -> None:
|
||||
"""Set JIANGCHANG_ACCOUNT_DEBUG_PATHS=1 to print paths in stderr."""
|
||||
v = (os.getenv("JIANGCHANG_ACCOUNT_DEBUG_PATHS") or "").strip().lower()
|
||||
if v in ("1", "true", "yes", "on"):
|
||||
print(_runtime_paths_debug_text(), file=sys.stderr)
|
||||
@@ -69,7 +51,7 @@ _WIN_RESERVED_NAMES = frozenset(
|
||||
|
||||
|
||||
def _fs_safe_segment(name: str, fallback: str) -> str:
|
||||
"""单级目录名:去掉 Windows 非法字符,避免末尾点/空格;空则用 fallback。"""
|
||||
"""Single directory segment: strip Windows-unsafe chars; empty -> fallback."""
|
||||
t = _WIN_FS_FORBIDDEN.sub("_", (name or "").strip())
|
||||
t = t.rstrip(" .")
|
||||
if not t:
|
||||
@@ -79,22 +61,33 @@ def _fs_safe_segment(name: str, fallback: str) -> str:
|
||||
return t
|
||||
|
||||
|
||||
def get_default_profile_dir(
|
||||
platform_key: str, phone: Optional[str], account_id: Optional[int] = None
|
||||
def get_default_profile_dir_for_web(
|
||||
platform_key: str,
|
||||
label: str,
|
||||
login_id: Optional[str] = None,
|
||||
domain: str = "generic",
|
||||
) -> str:
|
||||
"""
|
||||
默认可读路径:profiles/<平台展示名>/<手机号>/(便于在资源管理器中辨认)。
|
||||
无手机号时退化为 profiles/<平台展示名>/no_phone_<id>/。
|
||||
Generate a browser profile directory path.
|
||||
|
||||
Structure:
|
||||
{DATA_ROOT}/{USER_ID}/account-manager/profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix]/
|
||||
|
||||
This keeps profiles organized by domain and platform for easy
|
||||
identification in file explorer.
|
||||
"""
|
||||
label = _PLATFORM_PRIMARY_CN.get(platform_key, platform_key or "account")
|
||||
label_seg = _fs_safe_segment(label, _fs_safe_segment(platform_key or "", "platform"))
|
||||
ph = (phone or "").strip()
|
||||
if ph:
|
||||
phone_seg = _fs_safe_segment(ph, f"id_{account_id}" if account_id is not None else "phone")
|
||||
else:
|
||||
phone_seg = _fs_safe_segment(
|
||||
"", f"no_phone_{account_id}" if account_id is not None else "no_phone"
|
||||
)
|
||||
path = os.path.join(get_skill_data_dir(), "profiles", label_seg, phone_seg)
|
||||
label_seg = _fs_safe_segment(label, platform_key)
|
||||
login_suffix = ""
|
||||
if login_id:
|
||||
safe_login = _fs_safe_segment(login_id, "")
|
||||
if safe_login:
|
||||
login_suffix = f"_{safe_login[:16]}"
|
||||
path = os.path.join(
|
||||
get_skill_data_dir(),
|
||||
"profiles",
|
||||
_fs_safe_segment(domain, "generic"),
|
||||
_fs_safe_segment(platform_key, "unknown"),
|
||||
f"{label_seg}{login_suffix}",
|
||||
)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# account-manager tests
|
||||
56
tests/conftest.py
Normal file
56
tests/conftest.py
Normal 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
21
tests/run_tests.py
Normal 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))
|
||||
297
tests/test_account_service.py
Normal file
297
tests/test_account_service.py
Normal 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"
|
||||
44
tests/test_browser_open.py
Normal file
44
tests/test_browser_open.py
Normal 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")
|
||||
150
tests/test_cli_master_key.py
Normal file
150
tests/test_cli_master_key.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
MAIN_PY = os.path.join(_scripts, "main.py")
|
||||
|
||||
|
||||
def _iso_cli_env(data_root: str, uid: str = "cli_u") -> dict:
|
||||
"""Align skill roots so subprocess + in-process tests hit the same DB."""
|
||||
return {
|
||||
"JIANGCHANG_DATA_ROOT": data_root,
|
||||
"JIANGCHANG_USER_ID": uid,
|
||||
"CLAW_DATA_ROOT": data_root,
|
||||
"CLAW_USER_ID": uid,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ProcOut:
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
def _run_cli(env: dict, args: list[str], stdin: str | None = None) -> _ProcOut:
|
||||
merged = {**os.environ, **env}
|
||||
merged.pop("ACCOUNT_MANAGER_MASTER_KEY", None)
|
||||
inp = stdin.encode("utf-8") if stdin is not None else None
|
||||
r = subprocess.run(
|
||||
[sys.executable, MAIN_PY, *args],
|
||||
input=inp,
|
||||
capture_output=True,
|
||||
env=merged,
|
||||
cwd=os.path.dirname(_scripts),
|
||||
)
|
||||
stdout_txt = r.stdout.decode("utf-8") if r.stdout else ""
|
||||
stderr_txt = r.stderr.decode("utf-8", errors="replace") if r.stderr else ""
|
||||
return _ProcOut(returncode=r.returncode, stdout=stdout_txt, stderr=stderr_txt)
|
||||
|
||||
|
||||
def test_init_master_key_writes_file(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="cli_mk_")
|
||||
data_root = os.path.join(tmp, "dr")
|
||||
os.makedirs(data_root)
|
||||
env = _iso_cli_env(data_root)
|
||||
proc = _run_cli(env, ["account", "init-master-key"])
|
||||
assert proc.returncode == 0
|
||||
payload = json.loads(proc.stdout.strip().splitlines()[0])
|
||||
assert payload["success"] is True
|
||||
assert os.path.isfile(payload["path"])
|
||||
|
||||
|
||||
def test_init_master_key_rejects_duplicate(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="cli_mk_d_")
|
||||
data_root = os.path.join(tmp, "dr")
|
||||
os.makedirs(data_root)
|
||||
env = _iso_cli_env(data_root)
|
||||
p1 = _run_cli(env, ["account", "init-master-key"])
|
||||
assert p1.returncode == 0
|
||||
p2 = _run_cli(env, ["account", "init-master-key"])
|
||||
assert p2.returncode == 0
|
||||
payload = json.loads(p2.stdout.strip().splitlines()[0])
|
||||
assert payload["success"] is False
|
||||
assert payload["error"]["code"] == "ERR_MASTER_KEY_EXISTS"
|
||||
|
||||
|
||||
def test_export_import_roundtrip(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="cli_ex_")
|
||||
data_root = os.path.join(tmp, "dr")
|
||||
os.makedirs(data_root)
|
||||
env = _iso_cli_env(data_root)
|
||||
|
||||
init = _run_cli(env, ["account", "init-master-key"])
|
||||
assert init.returncode == 0
|
||||
|
||||
# Insert encrypted credential via repo in-process (same paths as CLI)
|
||||
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("JIANGCHANG_USER_ID", "cli_u")
|
||||
monkeypatch.setenv("CLAW_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("CLAW_USER_ID", "cli_u")
|
||||
monkeypatch.delenv("ACCOUNT_MANAGER_MASTER_KEY", raising=False)
|
||||
|
||||
from util.constants import SKILL_SLUG
|
||||
from util.logging_config import setup_skill_logging
|
||||
|
||||
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_cli_inline")
|
||||
|
||||
from db.connection import get_conn, init_db
|
||||
from db.credentials_repo import insert_credential_encrypted, read_credential_plaintext
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cid = insert_credential_encrypted(
|
||||
conn,
|
||||
account_id=None,
|
||||
platform_key="icbc_sim",
|
||||
credential_label="ICBC password label",
|
||||
credential_type="password",
|
||||
plaintext="roundtrip-secret",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=1_750_000_000,
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM credentials")
|
||||
assert int(cur.fetchone()[0]) == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
ex = _run_cli(env, ["account", "export-credentials", "--plaintext"])
|
||||
assert ex.returncode == 0
|
||||
arr = json.loads(ex.stdout.strip())
|
||||
assert len(arr) == 1
|
||||
assert arr[0]["plaintext"] == "roundtrip-secret"
|
||||
|
||||
imp = _run_cli(
|
||||
env,
|
||||
["account", "import-credentials", "--replace-all"],
|
||||
stdin=json.dumps(arr, ensure_ascii=False),
|
||||
)
|
||||
assert imp.returncode == 0
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM credentials WHERE secret_storage='local_encrypted'")
|
||||
assert int(cur.fetchone()[0]) == 1
|
||||
cur.execute("SELECT id FROM credentials LIMIT 1")
|
||||
new_id = int(cur.fetchone()[0])
|
||||
pt = read_credential_plaintext(conn, new_id)
|
||||
assert pt == "roundtrip-secret"
|
||||
finally:
|
||||
conn.close()
|
||||
511
tests/test_cli_stage_c.py
Normal file
511
tests/test_cli_stage_c.py
Normal file
@@ -0,0 +1,511 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Stage C: CLI — add-web v2, local_encrypted add-secret, pick-web fields, get-credential."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
MAIN_PY = os.path.join(_scripts, "main.py")
|
||||
|
||||
from util.constants import SKILL_SLUG # noqa: E402
|
||||
|
||||
|
||||
def _cli_env(roots: dict, **extra: str) -> dict:
|
||||
e = {**os.environ, **extra}
|
||||
e["JIANGCHANG_DATA_ROOT"] = roots["data_root"]
|
||||
e["JIANGCHANG_USER_ID"] = roots["user_id"]
|
||||
e["CLAW_DATA_ROOT"] = roots["data_root"]
|
||||
e["CLAW_USER_ID"] = roots["user_id"]
|
||||
return e
|
||||
|
||||
|
||||
def _db_path(roots: dict) -> str:
|
||||
return os.path.join(roots["data_root"], roots["user_id"], SKILL_SLUG, "account-manager.db")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ProcOut:
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
def _run_cli(env: dict, args: list[str], stdin: str | None = None) -> _ProcOut:
|
||||
inp = stdin.encode("utf-8") if stdin is not None else None
|
||||
r = subprocess.run(
|
||||
[sys.executable, MAIN_PY, *args],
|
||||
input=inp,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
cwd=os.path.dirname(_scripts),
|
||||
)
|
||||
stdout_txt = r.stdout.decode("utf-8") if r.stdout else ""
|
||||
stderr_txt = r.stderr.decode("utf-8", errors="replace") if r.stderr else ""
|
||||
return _ProcOut(returncode=r.returncode, stdout=stdout_txt, stderr=stderr_txt)
|
||||
|
||||
|
||||
def _parse_ok_data(line: str) -> dict:
|
||||
j = json.loads(line.strip().splitlines()[0])
|
||||
assert j.get("success") is True
|
||||
return j["data"]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iso_cli_env():
|
||||
"""Function-scoped data root for subprocess CLI tests."""
|
||||
tmp = tempfile.mkdtemp(prefix="cli_stage_c_")
|
||||
data_root = os.path.join(tmp, "claw-data")
|
||||
os.makedirs(data_root)
|
||||
uid = "77701"
|
||||
yield {"data_root": data_root, "user_id": uid, "tmp_dir": tmp}
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def with_master_key(monkeypatch):
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
key = Fernet.generate_key().decode("ascii")
|
||||
monkeypatch.setenv("ACCOUNT_MANAGER_MASTER_KEY", key)
|
||||
yield key
|
||||
|
||||
|
||||
class TestAddWebV2:
|
||||
def test_add_web_explicit_auth_strategy(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--label",
|
||||
"demo",
|
||||
"--auth-strategy",
|
||||
"qr_code_manual",
|
||||
"--session-persistent",
|
||||
"1",
|
||||
],
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
data = _parse_ok_data(proc.stdout)
|
||||
assert data["auth_strategy"] == "qr_code_manual"
|
||||
assert data["session_persistent"] == 1
|
||||
conn = sqlite3.connect(_db_path(iso_cli_env))
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT auth_strategy, session_persistent FROM accounts WHERE id = ?",
|
||||
(data["id"],),
|
||||
).fetchone()
|
||||
assert row == ("qr_code_manual", 1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_add_web_platform_default_maersk(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
["account", "add-web", "--platform", "maersk", "--label", "demo2"],
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
data = _parse_ok_data(proc.stdout)
|
||||
assert data["auth_strategy"] == "qr_code_manual"
|
||||
|
||||
def test_add_web_bad_auth_strategy(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--label",
|
||||
"x",
|
||||
"--auth-strategy",
|
||||
"foo",
|
||||
],
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
j = json.loads(proc.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is False
|
||||
assert j["error"]["code"] == "ERR_AUTH_STRATEGY_NOT_SUPPORTED"
|
||||
conn = sqlite3.connect(_db_path(iso_cli_env))
|
||||
try:
|
||||
n = conn.execute("SELECT COUNT(*) FROM accounts").fetchone()[0]
|
||||
assert n == 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_add_web_device_fingerprint_wrong_strategy(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--label",
|
||||
"x",
|
||||
"--auth-strategy",
|
||||
"password_auto",
|
||||
"--device-fingerprint",
|
||||
"fp1",
|
||||
],
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
j = json.loads(proc.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is False
|
||||
assert j["error"]["code"] == "ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE"
|
||||
|
||||
|
||||
class TestAddSecretLocalEncrypted:
|
||||
def test_add_secret_local_encrypted_success(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
"--label",
|
||||
"pw1",
|
||||
],
|
||||
stdin="my-pw\n",
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
data = _parse_ok_data(proc.stdout)
|
||||
assert data["secret_storage"] == "local_encrypted"
|
||||
conn = sqlite3.connect(_db_path(iso_cli_env))
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT secret_storage, secret_mask, length(secret_ciphertext) FROM credentials WHERE id = ?",
|
||||
(data["credential_id"],),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "local_encrypted"
|
||||
assert row[1] # mask non-empty
|
||||
assert row[2] and row[2] > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_add_secret_local_encrypted_requires_stdin(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--label",
|
||||
"x",
|
||||
],
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
j = json.loads(proc.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is False
|
||||
assert j["error"]["code"] == "ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN"
|
||||
|
||||
def test_add_secret_local_encrypted_type_not_allowed(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"browser_profile",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
],
|
||||
stdin="x\n",
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
j = json.loads(proc.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is False
|
||||
assert j["error"]["code"] == "ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED"
|
||||
|
||||
def test_add_secret_local_encrypted_master_missing(self, iso_cli_env, monkeypatch):
|
||||
monkeypatch.delenv("ACCOUNT_MANAGER_MASTER_KEY", raising=False)
|
||||
env = _cli_env(iso_cli_env)
|
||||
env.pop("ACCOUNT_MANAGER_MASTER_KEY", None)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
],
|
||||
stdin="pw\n",
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
j = json.loads(proc.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is False
|
||||
assert j["error"]["code"] == "ERR_MASTER_KEY_MISSING"
|
||||
|
||||
|
||||
class TestPickWebReturns:
|
||||
def test_pick_web_has_v2_fields(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aw = _run_cli(
|
||||
env,
|
||||
["account", "add-web", "--platform", "maersk", "--label", "pw"],
|
||||
)
|
||||
assert aw.returncode == 0
|
||||
aid = _parse_ok_data(aw.stdout)["id"]
|
||||
sec = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
"--account-id",
|
||||
str(aid),
|
||||
],
|
||||
stdin="secret-line\n",
|
||||
)
|
||||
assert sec.returncode == 0
|
||||
|
||||
pick = _run_cli(env, ["account", "pick-web", "--platform", "maersk"])
|
||||
assert pick.returncode == 0
|
||||
payload = json.loads(pick.stdout.strip().splitlines()[0])
|
||||
assert "auth_strategy" in payload
|
||||
assert "session_persistent" in payload
|
||||
assert "device_fingerprint" in payload
|
||||
|
||||
def test_pick_web_legacy_keys_present(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aw = _run_cli(
|
||||
env,
|
||||
["account", "add-web", "--platform", "maersk", "--label", "legacy"],
|
||||
)
|
||||
assert aw.returncode == 0
|
||||
pick = _run_cli(env, ["account", "pick-web", "--platform", "maersk"])
|
||||
assert pick.returncode == 0
|
||||
payload = json.loads(pick.stdout.strip().splitlines()[0])
|
||||
for k in ("id", "platform_key", "login_id", "profile_dir", "url"):
|
||||
assert k in payload
|
||||
|
||||
|
||||
class TestGetCredential:
|
||||
def test_get_credential_no_reveal_no_plaintext(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aw = _run_cli(
|
||||
env,
|
||||
["account", "add-web", "--platform", "maersk", "--label", "g1"],
|
||||
)
|
||||
aid = _parse_ok_data(aw.stdout)["id"]
|
||||
sec = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
"--account-id",
|
||||
str(aid),
|
||||
],
|
||||
stdin="hidden-pw\n",
|
||||
)
|
||||
assert sec.returncode == 0
|
||||
|
||||
g = _run_cli(env, ["account", "get-credential", str(aid)])
|
||||
assert g.returncode == 0
|
||||
j = json.loads(g.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is True
|
||||
assert "plaintext" not in j
|
||||
|
||||
def test_get_credential_reveal_missing_lease_token(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aw = _run_cli(env, ["account", "add-web", "--platform", "maersk", "--label", "g2"])
|
||||
aid = _parse_ok_data(aw.stdout)["id"]
|
||||
sec = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
"--account-id",
|
||||
str(aid),
|
||||
],
|
||||
stdin="p\n",
|
||||
)
|
||||
assert sec.returncode == 0
|
||||
|
||||
g = _run_cli(env, ["account", "get-credential", str(aid), "--reveal"])
|
||||
j = json.loads(g.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is False
|
||||
assert j["error"]["code"] == "ERR_LEASE_INVALID"
|
||||
|
||||
def test_get_credential_wrong_lease_account(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
a1 = _parse_ok_data(
|
||||
_run_cli(
|
||||
env,
|
||||
["account", "add-web", "--platform", "maersk", "--label", "acc1"],
|
||||
).stdout
|
||||
)["id"]
|
||||
sec1 = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
"--account-id",
|
||||
str(a1),
|
||||
],
|
||||
stdin="s1\n",
|
||||
)
|
||||
assert sec1.returncode == 0
|
||||
|
||||
lease_out = _run_cli(
|
||||
env,
|
||||
["account", "pick-web", "--platform", "maersk", "--lease", "--holder", "t"],
|
||||
)
|
||||
tok = json.loads(lease_out.stdout.strip().splitlines()[0])["lease_token"]
|
||||
|
||||
a2 = _parse_ok_data(
|
||||
_run_cli(
|
||||
env,
|
||||
["account", "add-web", "--platform", "maersk", "--label", "acc2"],
|
||||
).stdout
|
||||
)["id"]
|
||||
sec2 = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
"--account-id",
|
||||
str(a2),
|
||||
],
|
||||
stdin="s2\n",
|
||||
)
|
||||
assert sec2.returncode == 0
|
||||
|
||||
bad = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"get-credential",
|
||||
str(a2),
|
||||
"--reveal",
|
||||
"--lease-token",
|
||||
tok,
|
||||
],
|
||||
)
|
||||
j = json.loads(bad.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is False
|
||||
assert j["error"]["code"] == "ERR_LEASE_INVALID"
|
||||
|
||||
def test_get_credential_reveal_success(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
a1 = _parse_ok_data(
|
||||
_run_cli(
|
||||
env,
|
||||
["account", "add-web", "--platform", "maersk", "--label", "okc"],
|
||||
).stdout
|
||||
)["id"]
|
||||
secret_text = "correct-horse-battery"
|
||||
sec = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
"--account-id",
|
||||
str(a1),
|
||||
],
|
||||
stdin=secret_text + "\n",
|
||||
)
|
||||
assert sec.returncode == 0
|
||||
|
||||
lease_out = _run_cli(
|
||||
env,
|
||||
["account", "pick-web", "--platform", "maersk", "--lease", "--holder", "h"],
|
||||
)
|
||||
tok = json.loads(lease_out.stdout.strip().splitlines()[0])["lease_token"]
|
||||
|
||||
g = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"get-credential",
|
||||
str(a1),
|
||||
"--reveal",
|
||||
"--lease-token",
|
||||
tok,
|
||||
],
|
||||
)
|
||||
j = json.loads(g.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is True
|
||||
assert j["plaintext"] == secret_text
|
||||
340
tests/test_credentials.py
Normal file
340
tests/test_credentials.py
Normal 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}"
|
||||
143
tests/test_credentials_repo.py
Normal file
143
tests/test_credentials_repo.py
Normal file
@@ -0,0 +1,143 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from db.accounts_repo import insert_credential
|
||||
|
||||
|
||||
class TestCredentialsRepo:
|
||||
def test_encrypted_insert_and_read(self, monkeypatch, clean_db):
|
||||
from db.credentials_repo import (
|
||||
insert_credential_encrypted,
|
||||
list_credentials_by_account,
|
||||
read_credential_plaintext,
|
||||
)
|
||||
from service.master_key import ENV_MASTER_KEY
|
||||
|
||||
key = Fernet.generate_key()
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, key.decode("ascii"))
|
||||
|
||||
conn = clean_db
|
||||
now = 1_700_000_000
|
||||
aid = insert_credential_encrypted(
|
||||
conn,
|
||||
account_id=7,
|
||||
platform_key="icbc_sim",
|
||||
credential_label="工行密码",
|
||||
credential_type="password",
|
||||
plaintext="my-password",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
)
|
||||
conn.commit()
|
||||
pt = read_credential_plaintext(conn, aid)
|
||||
assert pt == "my-password"
|
||||
assert "my-password" not in "".join(
|
||||
str(v) for v in list_credentials_by_account(conn, 7)
|
||||
)
|
||||
|
||||
def test_insert_validation(self, monkeypatch, clean_db):
|
||||
from db.credentials_repo import insert_credential_encrypted, insert_credential_external
|
||||
from service.master_key import ENV_MASTER_KEY
|
||||
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, Fernet.generate_key().decode("ascii"))
|
||||
conn = clean_db
|
||||
now = 1
|
||||
with pytest.raises(ValueError, match="credential_type"):
|
||||
insert_credential_encrypted(
|
||||
conn,
|
||||
account_id=None,
|
||||
platform_key="x",
|
||||
credential_label="l",
|
||||
credential_type="weird",
|
||||
plaintext="x",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
)
|
||||
with pytest.raises(ValueError, match="plaintext"):
|
||||
insert_credential_encrypted(
|
||||
conn,
|
||||
account_id=None,
|
||||
platform_key="x",
|
||||
credential_label="l",
|
||||
credential_type="password",
|
||||
plaintext="",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
)
|
||||
with pytest.raises(ValueError, match="secret_storage"):
|
||||
insert_credential_external(
|
||||
conn,
|
||||
account_id=None,
|
||||
platform_key="x",
|
||||
credential_label="l",
|
||||
credential_type="password",
|
||||
secret_storage="bogus",
|
||||
secret_ref=None,
|
||||
secret_mask="",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
)
|
||||
|
||||
def test_list_has_no_sensitive_keys(self, monkeypatch, clean_db):
|
||||
from db.credentials_repo import insert_credential_encrypted, list_credentials_by_account
|
||||
from service.master_key import ENV_MASTER_KEY
|
||||
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, Fernet.generate_key().decode("ascii"))
|
||||
conn = clean_db
|
||||
now = 2
|
||||
insert_credential_encrypted(
|
||||
conn,
|
||||
account_id=9,
|
||||
platform_key="p",
|
||||
credential_label="a",
|
||||
credential_type="api_key",
|
||||
plaintext="k1",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
)
|
||||
insert_credential_encrypted(
|
||||
conn,
|
||||
account_id=9,
|
||||
platform_key="p",
|
||||
credential_label="b",
|
||||
credential_type="api_token",
|
||||
plaintext="k2",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=now + 1,
|
||||
)
|
||||
conn.commit()
|
||||
rows = list_credentials_by_account(conn, 9)
|
||||
assert len(rows) == 2
|
||||
for d in rows:
|
||||
assert "plaintext" not in d
|
||||
assert "secret_ciphertext" not in d
|
||||
|
||||
def test_none_storage_read_fails(self, clean_db):
|
||||
from db.credentials_repo import CredentialNotEncryptedError, read_credential_plaintext
|
||||
|
||||
conn = clean_db
|
||||
now = 3
|
||||
cid = insert_credential(
|
||||
conn,
|
||||
account_id=None,
|
||||
platform_key="p",
|
||||
credential_label="n",
|
||||
credential_type="note",
|
||||
secret_storage="none",
|
||||
secret_ref=None,
|
||||
secret_mask="",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
)
|
||||
conn.commit()
|
||||
with pytest.raises(CredentialNotEncryptedError) as ei:
|
||||
read_credential_plaintext(conn, cid)
|
||||
assert ei.value.code == "ERR_CREDENTIAL_NOT_ENCRYPTED"
|
||||
47
tests/test_crypto.py
Normal file
47
tests/test_crypto.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
from service.crypto import CredentialDecryptError, decrypt_secret, encrypt_secret # noqa: E402
|
||||
from service.master_key import ENV_MASTER_KEY # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def crypto_env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="crypto_test_")
|
||||
data_root = os.path.join(tmp, "claw-data")
|
||||
os.makedirs(data_root, exist_ok=True)
|
||||
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("JIANGCHANG_USER_ID", "c_uid")
|
||||
key = Fernet.generate_key()
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, key.decode("ascii"))
|
||||
yield key
|
||||
|
||||
|
||||
def test_encrypt_decrypt_roundtrip(crypto_env):
|
||||
pt = "my-password-123"
|
||||
ct = encrypt_secret(pt)
|
||||
assert decrypt_secret(ct) == pt
|
||||
|
||||
|
||||
def test_decrypt_invalid_token(crypto_env):
|
||||
with pytest.raises(CredentialDecryptError) as ei:
|
||||
decrypt_secret("AAAA-invalid-token-AAAA")
|
||||
assert ei.value.code == "ERR_CREDENTIAL_DECRYPT_FAILED"
|
||||
|
||||
|
||||
def test_decrypt_wrong_master_key(monkeypatch, crypto_env):
|
||||
pt = "secret-value"
|
||||
ct = encrypt_secret(pt)
|
||||
other = Fernet.generate_key().decode("ascii")
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, other)
|
||||
with pytest.raises(CredentialDecryptError):
|
||||
decrypt_secret(ct)
|
||||
398
tests/test_custom_platforms.py
Normal file
398
tests/test_custom_platforms.py
Normal 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
242
tests/test_lease.py
Normal 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
60
tests/test_master_key.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
from service.master_key import ( # noqa: E402
|
||||
ENV_MASTER_KEY,
|
||||
MasterKeyMissingError,
|
||||
load_master_key,
|
||||
write_master_key_file,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iso_mk(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="mk_test_")
|
||||
data_root = os.path.join(tmp, "claw-data")
|
||||
os.makedirs(data_root, exist_ok=True)
|
||||
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("JIANGCHANG_USER_ID", "mk_uid")
|
||||
monkeypatch.delenv(ENV_MASTER_KEY, raising=False)
|
||||
yield tmp
|
||||
|
||||
|
||||
def test_env_priority_over_file(iso_mk, monkeypatch):
|
||||
key_env = Fernet.generate_key()
|
||||
key_file = Fernet.generate_key()
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, key_env.decode("ascii"))
|
||||
write_master_key_file(key_file, allow_overwrite=True)
|
||||
assert load_master_key() == key_env
|
||||
|
||||
|
||||
def test_file_fallback(iso_mk):
|
||||
key_file = Fernet.generate_key()
|
||||
write_master_key_file(key_file, allow_overwrite=True)
|
||||
assert load_master_key() == key_file
|
||||
|
||||
|
||||
def test_missing_raises(iso_mk):
|
||||
with pytest.raises(MasterKeyMissingError) as ei:
|
||||
load_master_key()
|
||||
assert ei.value.code == "ERR_MASTER_KEY_MISSING"
|
||||
|
||||
|
||||
def test_invalid_env_falls_back_to_file(iso_mk, monkeypatch):
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, "not-a-real-key")
|
||||
with pytest.raises(MasterKeyMissingError):
|
||||
load_master_key()
|
||||
|
||||
key_file = Fernet.generate_key()
|
||||
write_master_key_file(key_file, allow_overwrite=True)
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, "not-a-real-key")
|
||||
assert load_master_key() == key_file
|
||||
18
tests/test_platform_alignment.py
Normal file
18
tests/test_platform_alignment.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Contract tests: PLATFORM_DEFAULT_AUTH_STRATEGY keys must resolve via PLATFORMS."""
|
||||
from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY
|
||||
from util.platforms import resolve_platform_key
|
||||
|
||||
|
||||
def test_platform_defaults_all_resolvable():
|
||||
for key in PLATFORM_DEFAULT_AUTH_STRATEGY:
|
||||
assert resolve_platform_key(key) == key
|
||||
|
||||
|
||||
def test_new_platform_aliases_work():
|
||||
assert resolve_platform_key("抖音") == "douyin"
|
||||
assert resolve_platform_key("douyin") == "douyin"
|
||||
assert resolve_platform_key("GitHub") == "github"
|
||||
assert resolve_platform_key("github") == "github"
|
||||
assert resolve_platform_key("OpenAI") == "openai"
|
||||
assert resolve_platform_key("openai") == "openai"
|
||||
76
tests/test_platform_dynamic.py
Normal file
76
tests/test_platform_dynamic.py
Normal 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
83
tests/test_platforms.py
Normal 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"
|
||||
75
tests/test_profile_shortcut.py
Normal file
75
tests/test_profile_shortcut.py
Normal 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}"
|
||||
157
tests/test_rpa_helpers_auth_flows.py
Normal file
157
tests/test_rpa_helpers_auth_flows.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_ensure_logged_in_dispatches_password_auto(monkeypatch):
|
||||
from service.rpa_helpers import auth_flows
|
||||
|
||||
monkeypatch.setattr(auth_flows, "_is_already_logged_in", lambda p, a: False)
|
||||
monkeypatch.setattr(auth_flows, "_poll_until", lambda *a, **k: True)
|
||||
|
||||
loc = MagicMock()
|
||||
page = MagicMock()
|
||||
page.url = "http://example.com"
|
||||
page.locator.return_value.first = loc
|
||||
|
||||
account = {"auth_strategy": "password_auto", "login_id": "u", "url": "http://x"}
|
||||
r = auth_flows.ensure_logged_in(page, account, credentials={"plaintext": "pw"})
|
||||
assert r.ok is True
|
||||
page.goto.assert_called()
|
||||
assert loc.type.call_count >= 2
|
||||
loc.click.assert_called()
|
||||
|
||||
|
||||
def test_ensure_logged_in_dispatches_qr_code_manual(monkeypatch):
|
||||
from service.rpa_helpers import auth_flows
|
||||
|
||||
monkeypatch.setattr(auth_flows, "_is_already_logged_in", lambda p, a: True)
|
||||
|
||||
page = MagicMock()
|
||||
account = {"auth_strategy": "qr_code_manual", "url": "http://x"}
|
||||
r = auth_flows.ensure_logged_in(page, account)
|
||||
assert r.ok is True
|
||||
|
||||
|
||||
def test_ensure_logged_in_client_certificate_raises():
|
||||
from service.rpa_helpers.auth_flows import AuthStrategyError, ensure_logged_in
|
||||
|
||||
page = MagicMock()
|
||||
account = {"auth_strategy": "client_certificate"}
|
||||
with pytest.raises(AuthStrategyError) as ei:
|
||||
ensure_logged_in(page, account)
|
||||
assert ei.value.code == "ERR_AUTH_STRATEGY_NOT_AUTOMATABLE"
|
||||
|
||||
|
||||
def test_ensure_logged_in_api_token_no_op():
|
||||
from service.rpa_helpers.auth_flows import ensure_logged_in
|
||||
|
||||
page = MagicMock()
|
||||
account = {"auth_strategy": "api_token"}
|
||||
r = ensure_logged_in(page, account)
|
||||
assert r.ok is True
|
||||
page.goto.assert_not_called()
|
||||
|
||||
|
||||
def test_ensure_logged_in_missing_credentials():
|
||||
from service.rpa_helpers.auth_flows import AuthStrategyError, ensure_logged_in
|
||||
|
||||
page = MagicMock()
|
||||
account = {"auth_strategy": "password_auto", "login_id": "u"}
|
||||
with pytest.raises(AuthStrategyError) as ei:
|
||||
ensure_logged_in(page, account, credentials=None)
|
||||
assert ei.value.code == "ERR_CREDENTIAL_MISSING"
|
||||
|
||||
|
||||
def test_ensure_logged_in_unknown_strategy():
|
||||
from service.rpa_helpers.auth_flows import AuthStrategyError, ensure_logged_in
|
||||
|
||||
page = MagicMock()
|
||||
account = {"auth_strategy": "bogus"}
|
||||
with pytest.raises(AuthStrategyError) as ei:
|
||||
ensure_logged_in(page, account)
|
||||
assert ei.value.code == "ERR_AUTH_STRATEGY_NOT_SUPPORTED"
|
||||
|
||||
|
||||
def test_ensure_logged_in_no_strategy():
|
||||
from service.rpa_helpers.auth_flows import AuthStrategyError, ensure_logged_in
|
||||
|
||||
page = MagicMock()
|
||||
account = {"auth_strategy": ""}
|
||||
with pytest.raises(AuthStrategyError) as ei:
|
||||
ensure_logged_in(page, account)
|
||||
assert ei.value.code == "ERR_AUTH_STRATEGY_NOT_SUPPORTED"
|
||||
|
||||
|
||||
def test_device_bound_fingerprint_mismatch(monkeypatch):
|
||||
from service.rpa_helpers.auth_flows import AuthStrategyError, ensure_logged_in
|
||||
|
||||
monkeypatch.setattr(
|
||||
"service.rpa_helpers.auth_flows._current_machine_fingerprint",
|
||||
lambda: "right_fp",
|
||||
)
|
||||
page = MagicMock()
|
||||
account = {
|
||||
"auth_strategy": "device_bound",
|
||||
"device_fingerprint": "wrong_fp",
|
||||
"login_id": "u",
|
||||
"url": "http://x",
|
||||
}
|
||||
with pytest.raises(AuthStrategyError) as ei:
|
||||
ensure_logged_in(page, account, credentials={"plaintext": "pw"})
|
||||
assert ei.value.code == "ERR_DEVICE_FINGERPRINT_MISMATCH"
|
||||
|
||||
|
||||
def test_is_already_logged_in_default_false():
|
||||
from service.rpa_helpers.auth_flows import _is_already_logged_in
|
||||
|
||||
p = MagicMock()
|
||||
p.url = "about:blank"
|
||||
assert _is_already_logged_in(p, {}) is False
|
||||
|
||||
p.url = "https://www.douyin.com"
|
||||
assert _is_already_logged_in(p, {}) is False
|
||||
|
||||
p.url = "https://www.douyin.com/user/abc"
|
||||
assert _is_already_logged_in(
|
||||
p,
|
||||
{"extra_json": {"logged_in_url_pattern": r"/user/"}},
|
||||
) is True
|
||||
|
||||
loc_first = MagicMock()
|
||||
loc_first.is_visible.return_value = True
|
||||
loc = MagicMock()
|
||||
loc.first = loc_first
|
||||
p2 = MagicMock()
|
||||
p2.url = "https://example.com/home"
|
||||
p2.locator.return_value = loc
|
||||
assert _is_already_logged_in(
|
||||
p2,
|
||||
{"extra_json": {"logged_in_selector": "#avatar"}},
|
||||
) is True
|
||||
p2.locator.assert_called_with("#avatar")
|
||||
|
||||
|
||||
def test_qr_code_manual_default_no_hooks():
|
||||
from service.rpa_helpers.auth_flows import ensure_logged_in
|
||||
|
||||
qr_first = MagicMock()
|
||||
qr_first.wait_for.side_effect = TimeoutError("mock qr timeout")
|
||||
qr_loc = MagicMock()
|
||||
qr_loc.first = qr_first
|
||||
page = MagicMock()
|
||||
page.url = "about:blank"
|
||||
page.locator.return_value = qr_loc
|
||||
|
||||
account = {"auth_strategy": "qr_code_manual", "url": "http://login.example"}
|
||||
r = ensure_logged_in(page, account)
|
||||
page.goto.assert_called_once_with(
|
||||
"http://login.example",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=60000,
|
||||
)
|
||||
qr_first.wait_for.assert_called_once()
|
||||
assert r.ok is False
|
||||
assert r.message == "qr code not found"
|
||||
|
||||
61
tests/test_rpa_helpers_browser.py
Normal file
61
tests/test_rpa_helpers_browser.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_playwright(monkeypatch):
|
||||
pw = MagicMock()
|
||||
ctx = MagicMock()
|
||||
page = MagicMock()
|
||||
ctx.pages = [page]
|
||||
pw.chromium.launch_persistent_context.return_value = ctx
|
||||
|
||||
sp = MagicMock()
|
||||
sp.start.return_value = pw
|
||||
monkeypatch.setattr("service.rpa_helpers.browser.sync_playwright", lambda: sp)
|
||||
|
||||
return {"pw": pw, "ctx": ctx, "page": page, "sp": sp}
|
||||
|
||||
|
||||
def test_launch_browser_uses_chrome_channel_default(mock_playwright, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("OPENCLAW_PLAYWRIGHT_STEALTH", "1")
|
||||
monkeypatch.setattr(
|
||||
"service.rpa_helpers.browser.resolve_chromium_channel",
|
||||
lambda: "chrome",
|
||||
)
|
||||
prof = tmp_path / "p1"
|
||||
from service.rpa_helpers.browser import launch_browser_with_profile
|
||||
|
||||
ctx, page = launch_browser_with_profile(str(prof))
|
||||
pw = mock_playwright["pw"]
|
||||
kw = pw.chromium.launch_persistent_context.call_args.kwargs
|
||||
assert kw["channel"] == "chrome"
|
||||
assert "--disable-blink-features=AutomationControlled" in kw["args"]
|
||||
assert ctx is mock_playwright["ctx"]
|
||||
assert page is mock_playwright["page"]
|
||||
|
||||
|
||||
def test_launch_browser_respects_explicit_channel(mock_playwright, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("OPENCLAW_PLAYWRIGHT_STEALTH", "1")
|
||||
prof = tmp_path / "p2"
|
||||
from service.rpa_helpers.browser import launch_browser_with_profile
|
||||
|
||||
launch_browser_with_profile(str(prof), channel="msedge")
|
||||
kw = mock_playwright["pw"].chromium.launch_persistent_context.call_args.kwargs
|
||||
assert kw["channel"] == "msedge"
|
||||
|
||||
|
||||
def test_launch_browser_creates_profile_dir(mock_playwright, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("OPENCLAW_PLAYWRIGHT_STEALTH", "1")
|
||||
monkeypatch.setattr(
|
||||
"service.rpa_helpers.browser.resolve_chromium_channel",
|
||||
lambda: "chrome",
|
||||
)
|
||||
prof = tmp_path / "new_prof"
|
||||
assert not prof.is_dir()
|
||||
from service.rpa_helpers.browser import launch_browser_with_profile
|
||||
|
||||
launch_browser_with_profile(str(prof))
|
||||
assert prof.is_dir()
|
||||
54
tests/test_rpa_helpers_human.py
Normal file
54
tests/test_rpa_helpers_human.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_pause_respects_range(monkeypatch):
|
||||
sleeps = []
|
||||
|
||||
def _rec(s):
|
||||
sleeps.append(s)
|
||||
|
||||
monkeypatch.setattr("service.rpa_helpers.human.time.sleep", _rec)
|
||||
from service.rpa_helpers.human import pause
|
||||
|
||||
for _ in range(10):
|
||||
pause(50, 100)
|
||||
assert len(sleeps) == 10
|
||||
for s in sleeps:
|
||||
assert 0.04 <= s <= 0.12
|
||||
|
||||
|
||||
def test_human_type_calls_locator_methods():
|
||||
from service.rpa_helpers.human import human_type
|
||||
|
||||
loc = MagicMock()
|
||||
human_type(loc, "hello")
|
||||
loc.scroll_into_view_if_needed.assert_called()
|
||||
loc.click.assert_called()
|
||||
loc.type.assert_called_once()
|
||||
args, kwargs = loc.type.call_args
|
||||
assert args[0] == "hello"
|
||||
assert "delay" in kwargs
|
||||
assert 50 <= kwargs["delay"] <= 200
|
||||
|
||||
|
||||
def test_human_click_basic():
|
||||
from service.rpa_helpers.human import human_click
|
||||
|
||||
loc = MagicMock()
|
||||
human_click(loc)
|
||||
loc.scroll_into_view_if_needed.assert_called()
|
||||
loc.click.assert_called()
|
||||
|
||||
|
||||
def test_human_select_basic():
|
||||
from service.rpa_helpers.human import human_select
|
||||
|
||||
loc = MagicMock()
|
||||
human_select(loc, "opt1")
|
||||
loc.scroll_into_view_if_needed.assert_called()
|
||||
loc.click.assert_called()
|
||||
assert loc.select_option.call_count >= 1
|
||||
|
||||
377
tests/test_schema_migration.py
Normal file
377
tests/test_schema_migration.py
Normal file
@@ -0,0 +1,377 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for v2 schema DDL and migrate_v1_to_v2 / ensure_schema."""
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
from util.constants import SKILL_SLUG # noqa: E402
|
||||
|
||||
|
||||
# Minimal v1-like DDL (no auth_strategy / session_persistent / device_fingerprint /
|
||||
# platforms.default_auth_strategy; credentials without account_id).
|
||||
_V1_DDL = """
|
||||
CREATE TABLE platforms (
|
||||
platform_key TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
provider_code TEXT,
|
||||
default_url TEXT,
|
||||
aliases_json TEXT NOT NULL DEFAULT '[]',
|
||||
capabilities_json TEXT NOT NULL DEFAULT '{}',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform_key TEXT NOT NULL,
|
||||
account_label TEXT NOT NULL,
|
||||
login_id TEXT,
|
||||
login_id_type TEXT NOT NULL DEFAULT 'unknown',
|
||||
tenant_id TEXT,
|
||||
environment TEXT NOT NULL DEFAULT 'production',
|
||||
role TEXT NOT NULL DEFAULT 'default',
|
||||
provider_code TEXT,
|
||||
profile_dir TEXT,
|
||||
url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
session_status TEXT NOT NULL DEFAULT 'unknown',
|
||||
last_used_at INTEGER,
|
||||
last_login_check_at INTEGER,
|
||||
last_error_code TEXT,
|
||||
last_error_message TEXT,
|
||||
extra_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform_key TEXT NOT NULL,
|
||||
credential_label TEXT NOT NULL,
|
||||
credential_type TEXT NOT NULL,
|
||||
secret_storage TEXT NOT NULL,
|
||||
secret_ref TEXT,
|
||||
secret_mask TEXT,
|
||||
expires_at INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
last_used_at INTEGER,
|
||||
extra_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE account_leases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
lease_token TEXT NOT NULL UNIQUE,
|
||||
holder TEXT NOT NULL,
|
||||
purpose TEXT,
|
||||
expires_at INTEGER NOT NULL,
|
||||
heartbeat_at INTEGER,
|
||||
released_at INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _db_path_for_env(root: str, uid: str) -> str:
|
||||
return os.path.join(root, uid, SKILL_SLUG, "account-manager.db")
|
||||
|
||||
|
||||
def _prep_logging():
|
||||
from util.logging_config import setup_skill_logging
|
||||
|
||||
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_migration_test")
|
||||
|
||||
|
||||
def _column_names(conn: sqlite3.Connection, table: str) -> list[str]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"PRAGMA table_info({table})")
|
||||
return [row[1] for row in cur.fetchall()]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iso_env(monkeypatch):
|
||||
with tempfile.TemporaryDirectory(prefix="acc_mgr_schema_", ignore_cleanup_errors=True) as tmp:
|
||||
data_root = os.path.join(tmp, "data-root")
|
||||
os.makedirs(data_root)
|
||||
uid = "migration_uid"
|
||||
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("JIANGCHANG_USER_ID", uid)
|
||||
db_path = _db_path_for_env(data_root, uid)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
_prep_logging()
|
||||
yield {"tmp": tmp, "data_root": data_root, "uid": uid, "db_path": db_path}
|
||||
|
||||
|
||||
class TestFreshSchema:
|
||||
def test_fresh_schema_has_v2_columns_and_meta(self, iso_env):
|
||||
from db.connection import init_db, get_conn
|
||||
|
||||
if os.path.isfile(iso_env["db_path"]):
|
||||
os.remove(iso_env["db_path"])
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cols_accounts = _column_names(conn, "accounts")
|
||||
assert "auth_strategy" in cols_accounts
|
||||
assert "session_persistent" in cols_accounts
|
||||
assert "device_fingerprint" in cols_accounts
|
||||
cols_plat = _column_names(conn, "platforms")
|
||||
assert "default_auth_strategy" in cols_plat
|
||||
cols_cred = _column_names(conn, "credentials")
|
||||
assert "account_id" in cols_cred
|
||||
assert "secret_ciphertext" in cols_cred
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT value FROM _schema_meta WHERE key = ?",
|
||||
("schema_version",),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
assert row is not None and row[0] == "2.1"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestMigrateFromV1:
|
||||
def test_v1_to_v2_migrates_and_preserves_rows(self, iso_env, capsys):
|
||||
db_path = iso_env["db_path"]
|
||||
if os.path.isfile(db_path):
|
||||
os.remove(db_path)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
raw = sqlite3.connect(db_path)
|
||||
try:
|
||||
raw.executescript(_V1_DDL)
|
||||
now = int(time.time())
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
|
||||
default_url, aliases_json, capabilities_json, enabled, created_at, updated_at)
|
||||
VALUES ('keep_me', 'Keep', 'generic', NULL, '', '[]', '{}', 1, ?, ?)
|
||||
""",
|
||||
(now, now),
|
||||
)
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO accounts (platform_key, account_label, login_id, login_id_type,
|
||||
tenant_id, environment, role, provider_code, profile_dir, url,
|
||||
status, session_status, extra_json, created_at, updated_at)
|
||||
VALUES ('keep_me', 'acc', 'u@test.com', 'email', NULL, 'production',
|
||||
'booking', NULL, NULL, '', 'active', 'unknown', '{}', ?, ?)
|
||||
""",
|
||||
(now, now),
|
||||
)
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
from db.connection import init_db, get_conn
|
||||
|
||||
init_db()
|
||||
err = capsys.readouterr().err
|
||||
assert "[migration] account-manager v1→v2:" in err
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM accounts WHERE login_id = ?", ("u@test.com",))
|
||||
assert int(cur.fetchone()[0]) == 1
|
||||
cur.execute(
|
||||
"SELECT auth_strategy, session_persistent FROM accounts WHERE login_id = ?",
|
||||
("u@test.com",),
|
||||
)
|
||||
strat, sp = cur.fetchone()
|
||||
assert strat == "password_auto"
|
||||
assert int(sp) == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_migration_idempotent(self, iso_env, capsys):
|
||||
db_path = iso_env["db_path"]
|
||||
if os.path.isfile(db_path):
|
||||
os.remove(db_path)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
raw = sqlite3.connect(db_path)
|
||||
try:
|
||||
raw.executescript(_V1_DDL)
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
from db.connection import init_db
|
||||
|
||||
init_db()
|
||||
capsys.readouterr()
|
||||
init_db()
|
||||
err = capsys.readouterr().err
|
||||
assert "v1→v2" not in err
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
cols = _column_names(conn, "accounts")
|
||||
assert cols.count("auth_strategy") == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_platform_default_mapping_douyin(self, iso_env):
|
||||
db_path = iso_env["db_path"]
|
||||
if os.path.isfile(db_path):
|
||||
os.remove(db_path)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
raw = sqlite3.connect(db_path)
|
||||
now = int(time.time())
|
||||
try:
|
||||
raw.executescript(_V1_DDL)
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
|
||||
default_url, aliases_json, capabilities_json, enabled, created_at, updated_at)
|
||||
VALUES ('douyin', 'Douyin', 'content', NULL, '', '[]', '{}', 1, ?, ?)
|
||||
""",
|
||||
(now, now),
|
||||
)
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
from db.connection import init_db, get_conn
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT default_auth_strategy FROM platforms WHERE platform_key = ?",
|
||||
("douyin",),
|
||||
)
|
||||
assert cur.fetchone()[0] == "qr_code_manual"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_accounts_auth_strategy_priority(self, iso_env):
|
||||
db_path = iso_env["db_path"]
|
||||
if os.path.isfile(db_path):
|
||||
os.remove(db_path)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
raw = sqlite3.connect(db_path)
|
||||
now = int(time.time())
|
||||
try:
|
||||
raw.executescript(_V1_DDL)
|
||||
for pk, dn in (
|
||||
("maersk", "Maersk"),
|
||||
("unknown_platform", "Unknown"),
|
||||
):
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
|
||||
default_url, aliases_json, capabilities_json, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, 'generic', NULL, '', '[]', '{}', 1, ?, ?)
|
||||
""",
|
||||
(pk, dn, now, now),
|
||||
)
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO accounts (platform_key, account_label, login_id, login_id_type,
|
||||
tenant_id, environment, role, provider_code, profile_dir, url,
|
||||
status, session_status, extra_json, created_at, updated_at)
|
||||
VALUES ('maersk', 'm', NULL, 'unknown', NULL, 'production',
|
||||
'booking', NULL, NULL, '', 'active', 'unknown', '{}', ?, ?)
|
||||
""",
|
||||
(now, now),
|
||||
)
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO accounts (platform_key, account_label, login_id, login_id_type,
|
||||
tenant_id, environment, role, provider_code, profile_dir, url,
|
||||
status, session_status, extra_json, created_at, updated_at)
|
||||
VALUES ('unknown_platform', 'u', NULL, 'unknown', NULL, 'production',
|
||||
'booking', NULL, NULL, '', 'active', 'unknown', '{}', ?, ?)
|
||||
""",
|
||||
(now, now),
|
||||
)
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
from db.connection import init_db, get_conn
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT auth_strategy FROM accounts WHERE platform_key = ? ORDER BY id",
|
||||
("maersk",),
|
||||
)
|
||||
assert cur.fetchone()[0] == "qr_code_manual"
|
||||
cur.execute(
|
||||
"SELECT auth_strategy FROM accounts WHERE platform_key = ? ORDER BY id",
|
||||
("unknown_platform",),
|
||||
)
|
||||
assert cur.fetchone()[0] == "password_auto"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestInsertAccountValidation:
|
||||
def test_insert_account_rejects_invalid_strategy(self, iso_env):
|
||||
from db.connection import init_db, get_conn
|
||||
from db.accounts_repo import insert_account
|
||||
from db.auth_strategy import AUTH_STRATEGY_QR_CODE_MANUAL
|
||||
|
||||
if os.path.isfile(iso_env["db_path"]):
|
||||
os.remove(iso_env["db_path"])
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
now = int(time.time())
|
||||
with pytest.raises(ValueError, match="invalid auth_strategy"):
|
||||
insert_account(
|
||||
conn,
|
||||
platform_key="x",
|
||||
account_label="l",
|
||||
login_id=None,
|
||||
login_id_type="unknown",
|
||||
tenant_id=None,
|
||||
environment="production",
|
||||
role="booking",
|
||||
provider_code=None,
|
||||
profile_dir=None,
|
||||
url=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
auth_strategy="not_a_strategy",
|
||||
)
|
||||
aid = insert_account(
|
||||
conn,
|
||||
platform_key="x",
|
||||
account_label="l",
|
||||
login_id=None,
|
||||
login_id_type="unknown",
|
||||
tenant_id=None,
|
||||
environment="production",
|
||||
role="booking",
|
||||
provider_code=None,
|
||||
profile_dir=None,
|
||||
url=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
auth_strategy=AUTH_STRATEGY_QR_CODE_MANUAL,
|
||||
)
|
||||
assert isinstance(aid, int) and aid > 0
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
104
tests/test_schema_migration_v21.py
Normal file
104
tests/test_schema_migration_v21.py
Normal file
@@ -0,0 +1,104 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
from util.constants import SKILL_SLUG # noqa: E402
|
||||
|
||||
|
||||
def _db_path(root: str, uid: str) -> str:
|
||||
return os.path.join(root, uid, SKILL_SLUG, "account-manager.db")
|
||||
|
||||
|
||||
def _prep_logging():
|
||||
from util.logging_config import setup_skill_logging
|
||||
|
||||
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_schema_v21")
|
||||
|
||||
|
||||
def _column_names(conn: sqlite3.Connection, table: str) -> list[str]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"PRAGMA table_info({table})")
|
||||
return [row[1] for row in cur.fetchall()]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iso_v21(monkeypatch):
|
||||
with tempfile.TemporaryDirectory(prefix="acc_mgr_v21_", ignore_cleanup_errors=True) as tmp:
|
||||
data_root = os.path.join(tmp, "data-root")
|
||||
os.makedirs(data_root)
|
||||
uid = "v21_uid"
|
||||
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("JIANGCHANG_USER_ID", uid)
|
||||
db_path = _db_path(data_root, uid)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
_prep_logging()
|
||||
yield {"tmp": tmp, "db_path": db_path}
|
||||
|
||||
|
||||
class TestSchemaV21:
|
||||
def test_fresh_has_secret_ciphertext_and_meta(self, iso_v21):
|
||||
from db.connection import get_conn, init_db
|
||||
|
||||
if os.path.isfile(iso_v21["db_path"]):
|
||||
os.remove(iso_v21["db_path"])
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
assert "secret_ciphertext" in _column_names(conn, "credentials")
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT value FROM _schema_meta WHERE key = ?",
|
||||
("schema_version",),
|
||||
)
|
||||
assert cur.fetchone()[0] == "2.1"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_v2_to_v21_idempotent(self, iso_v21, capsys):
|
||||
from db.connection import get_conn, init_db
|
||||
|
||||
db_path = iso_v21["db_path"]
|
||||
if os.path.isfile(db_path):
|
||||
os.remove(db_path)
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute("ALTER TABLE credentials DROP COLUMN secret_ciphertext")
|
||||
conn.execute(
|
||||
"UPDATE _schema_meta SET value = ? WHERE key = ?",
|
||||
("2", "schema_version"),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
capsys.readouterr()
|
||||
init_db()
|
||||
err = capsys.readouterr().err
|
||||
assert "v2→v2.1" in err
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
assert "secret_ciphertext" in _column_names(conn, "credentials")
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT value FROM _schema_meta WHERE key = ?",
|
||||
("schema_version",),
|
||||
)
|
||||
assert cur.fetchone()[0] == "2.1"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
capsys.readouterr()
|
||||
init_db()
|
||||
err2 = capsys.readouterr().err
|
||||
assert "v2→v2.1" not in err2
|
||||
68
tests/test_seed_platforms_upgrade.py
Normal file
68
tests/test_seed_platforms_upgrade.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
from db.connection import get_conn, init_db # noqa: E402
|
||||
from util.platforms import seed_platforms # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iso_seed_env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="seed_plat_")
|
||||
data_root = os.path.join(tmp, "claw-data")
|
||||
os.makedirs(data_root)
|
||||
uid = "seed_user_1"
|
||||
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("JIANGCHANG_USER_ID", uid)
|
||||
monkeypatch.setenv("CLAW_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("CLAW_USER_ID", uid)
|
||||
yield
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def _default_auth_maersk() -> str | None:
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT default_auth_strategy FROM platforms WHERE platform_key = ?",
|
||||
("maersk",),
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_default_auth_strategy_filled_on_first_seed(iso_seed_env):
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
seed_platforms(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
assert _default_auth_maersk() == "qr_code_manual"
|
||||
|
||||
|
||||
def test_default_auth_strategy_updated_on_reseed(iso_seed_env):
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
seed_platforms(conn)
|
||||
conn.execute(
|
||||
"UPDATE platforms SET default_auth_strategy = ? WHERE platform_key = ?",
|
||||
("password_auto", "maersk"),
|
||||
)
|
||||
conn.commit()
|
||||
seed_platforms(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
assert _default_auth_maersk() == "qr_code_manual"
|
||||
33
tests/test_usage.py
Normal file
33
tests/test_usage.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Smoke test: built-in CLI usage lists routed account subcommands."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
|
||||
|
||||
def test_usage_includes_all_account_subcommands():
|
||||
env = {**os.environ, "PYTHONPATH": _scripts}
|
||||
r = subprocess.run(
|
||||
[sys.executable, "-m", "cli.app"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=_scripts,
|
||||
)
|
||||
assert r.returncode == 1
|
||||
usage = r.stdout
|
||||
for cmd in (
|
||||
"init-master-key",
|
||||
"export-credentials",
|
||||
"import-credentials",
|
||||
"add-web",
|
||||
"get-credential",
|
||||
"pick-web",
|
||||
):
|
||||
assert cmd in usage, f"USAGE 缺少 {cmd}"
|
||||
332
tests/test_v1_compatibility.py
Normal file
332
tests/test_v1_compatibility.py
Normal file
@@ -0,0 +1,332 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""v1 CLI compatibility — subprocess-only, mirrors monitor-competitor-rates calling patterns."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor, wait
|
||||
|
||||
import pytest
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
MAIN_PY = os.path.join(_scripts, "main.py")
|
||||
|
||||
|
||||
def _cli_env(roots: dict) -> dict:
|
||||
e = {**os.environ}
|
||||
e["JIANGCHANG_DATA_ROOT"] = roots["data_root"]
|
||||
e["JIANGCHANG_USER_ID"] = roots["user_id"]
|
||||
e["CLAW_DATA_ROOT"] = roots["data_root"]
|
||||
e["CLAW_USER_ID"] = roots["user_id"]
|
||||
return e
|
||||
|
||||
|
||||
def _run_cli(env: dict, args: list[str], stdin: str | None = None) -> subprocess.CompletedProcess:
|
||||
inp = stdin.encode("utf-8") if stdin is not None else None
|
||||
return subprocess.run(
|
||||
[sys.executable, MAIN_PY, *args],
|
||||
input=inp,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
cwd=os.path.dirname(_scripts),
|
||||
)
|
||||
|
||||
|
||||
def _parse_last_json(stdout: str) -> dict:
|
||||
lines = [ln for ln in (stdout or "").strip().split("\n") if ln.strip()]
|
||||
assert lines, "stdout had no non-empty lines"
|
||||
return json.loads(lines[-1])
|
||||
|
||||
|
||||
def run_cli_json_last(env: dict, args: list[str], *, stdin: str | None = None) -> dict:
|
||||
"""Parse **last** stdout line as JSON (monitor `_parse_stdout_json` semantics)."""
|
||||
r = _run_cli(env, args, stdin=stdin)
|
||||
assert r.returncode == 0, (r.stdout, r.stderr)
|
||||
return _parse_last_json(r.stdout.decode("utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iso_cli_env():
|
||||
tmp = tempfile.mkdtemp(prefix="v1_compat_")
|
||||
data_root = os.path.join(tmp, "claw-data")
|
||||
os.makedirs(data_root)
|
||||
uid = "compat_user"
|
||||
yield {"data_root": data_root, "user_id": uid, "tmp_dir": tmp}
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def _add_monitor_account(env: dict) -> dict:
|
||||
out = run_cli_json_last(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--label",
|
||||
"monitor-test",
|
||||
"--environment",
|
||||
"simulator",
|
||||
"--role",
|
||||
"booking",
|
||||
"--tenant-id",
|
||||
"tenant-test",
|
||||
],
|
||||
)
|
||||
assert out.get("success") is True
|
||||
return out["data"]
|
||||
|
||||
|
||||
class TestV1PickWeb:
|
||||
def test_pick_web_returns_v1_shape(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
add_data = _add_monitor_account(env)
|
||||
aid = add_data["id"]
|
||||
assert isinstance(aid, int)
|
||||
|
||||
pick = run_cli_json_last(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--environment",
|
||||
"simulator",
|
||||
"--role",
|
||||
"booking",
|
||||
"--lease",
|
||||
"--holder",
|
||||
"monitor-competitor-rates",
|
||||
"--purpose",
|
||||
"maersk_sim_rpa",
|
||||
"--ttl-sec",
|
||||
"1800",
|
||||
],
|
||||
)
|
||||
|
||||
for key in ("id", "platform_key", "environment", "role", "profile_dir", "lease_token"):
|
||||
assert key in pick, f"pick-web 缺少 v1 字段 {key}"
|
||||
|
||||
assert isinstance(pick["id"], int)
|
||||
assert isinstance(pick["lease_token"], str) and len(pick["lease_token"]) > 0
|
||||
assert pick["platform_key"] == "maersk"
|
||||
assert pick["environment"] == "simulator"
|
||||
assert pick["role"] == "booking"
|
||||
|
||||
for v2_key in ("auth_strategy", "session_persistent", "device_fingerprint"):
|
||||
assert v2_key in pick, f"v2 字段 {v2_key} 应在 pick-web 返回中"
|
||||
|
||||
assert len(pick) >= 10
|
||||
|
||||
def test_pick_web_no_account_returns_legacy_error_code(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--environment",
|
||||
"simulator",
|
||||
"--role",
|
||||
"booking",
|
||||
"--lease",
|
||||
"--holder",
|
||||
"monitor-competitor-rates",
|
||||
],
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
payload = _parse_last_json(proc.stdout.decode("utf-8"))
|
||||
assert payload.get("success") is False
|
||||
assert payload["error"]["code"] == "ERROR:NO_ACCOUNT"
|
||||
|
||||
def test_pick_web_lease_race_returns_legacy_error_shape(self, iso_cli_env):
|
||||
"""Parallel picks share one account — second outcome may be NO_ACCOUNT or LEASE_CONFLICT."""
|
||||
env = _cli_env(iso_cli_env)
|
||||
_add_monitor_account(env)
|
||||
|
||||
args = [
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--environment",
|
||||
"simulator",
|
||||
"--role",
|
||||
"booking",
|
||||
"--lease",
|
||||
"--holder",
|
||||
"monitor-competitor-rates",
|
||||
"--ttl-sec",
|
||||
"3600",
|
||||
]
|
||||
|
||||
def _pick():
|
||||
return _run_cli(env, args)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as ex:
|
||||
f1 = ex.submit(_pick)
|
||||
f2 = ex.submit(_pick)
|
||||
wait([f1, f2])
|
||||
proc_a = f1.result()
|
||||
proc_b = f2.result()
|
||||
|
||||
payloads = []
|
||||
for proc in (proc_a, proc_b):
|
||||
assert proc.returncode == 0
|
||||
payloads.append(_parse_last_json(proc.stdout.decode("utf-8")))
|
||||
|
||||
codes = []
|
||||
successes = 0
|
||||
for p in payloads:
|
||||
if p.get("success") is False:
|
||||
codes.append(p["error"]["code"])
|
||||
elif "lease_token" in p:
|
||||
successes += 1
|
||||
|
||||
assert successes >= 1
|
||||
assert "ERROR:LEASE_CONFLICT" in codes or "ERROR:NO_ACCOUNT" in codes
|
||||
|
||||
|
||||
class TestV1LeaseAndMarks:
|
||||
def test_lease_release_returns_success_json(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
_add_monitor_account(env)
|
||||
pick = run_cli_json_last(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--environment",
|
||||
"simulator",
|
||||
"--role",
|
||||
"booking",
|
||||
"--lease",
|
||||
"--holder",
|
||||
"monitor-competitor-rates",
|
||||
"--ttl-sec",
|
||||
"900",
|
||||
],
|
||||
)
|
||||
tok = pick["lease_token"]
|
||||
rel = run_cli_json_last(env, ["lease", "release", tok])
|
||||
assert rel.get("success") is True
|
||||
assert rel["data"]["status"] == "released"
|
||||
|
||||
def test_mark_used_returns_success(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aid = _add_monitor_account(env)["id"]
|
||||
out = run_cli_json_last(env, ["account", "mark-used", str(aid)])
|
||||
assert out.get("success") is True
|
||||
assert int(out["data"]["id"]) == aid
|
||||
|
||||
def test_mark_session_accepts_v1_status_values(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aid = _add_monitor_account(env)["id"]
|
||||
for status in ("likely_valid", "needs_login", "unknown"):
|
||||
out = run_cli_json_last(
|
||||
env,
|
||||
["account", "mark-session", str(aid), "--session-status", status],
|
||||
)
|
||||
assert out.get("success") is True
|
||||
assert out["data"]["session_status"] == status
|
||||
|
||||
def test_mark_error_accepts_long_message(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aid = _add_monitor_account(env)["id"]
|
||||
long_msg = "x" * 600
|
||||
out = run_cli_json_last(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"mark-error",
|
||||
str(aid),
|
||||
"--code",
|
||||
"ERR_TEST",
|
||||
"--message",
|
||||
long_msg,
|
||||
],
|
||||
)
|
||||
assert out.get("success") is True
|
||||
assert out["data"]["error_code"] == "ERR_TEST"
|
||||
|
||||
|
||||
class TestV1EndToEnd:
|
||||
def test_full_monitor_sequence_end_to_end(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aid = _add_monitor_account(env)["id"]
|
||||
|
||||
pick1 = run_cli_json_last(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--environment",
|
||||
"simulator",
|
||||
"--role",
|
||||
"booking",
|
||||
"--lease",
|
||||
"--holder",
|
||||
"monitor-competitor-rates",
|
||||
"--purpose",
|
||||
"maersk_sim_rpa",
|
||||
"--ttl-sec",
|
||||
"900",
|
||||
],
|
||||
)
|
||||
tok = pick1["lease_token"]
|
||||
assert pick1["id"] == aid
|
||||
|
||||
run_cli_json_last(env, ["account", "mark-used", str(aid)])
|
||||
run_cli_json_last(
|
||||
env,
|
||||
["account", "mark-session", str(aid), "--session-status", "likely_valid"],
|
||||
)
|
||||
run_cli_json_last(env, ["lease", "release", tok])
|
||||
|
||||
pick2 = run_cli_json_last(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--environment",
|
||||
"simulator",
|
||||
"--role",
|
||||
"booking",
|
||||
"--lease",
|
||||
"--holder",
|
||||
"monitor-competitor-rates",
|
||||
"--ttl-sec",
|
||||
"900",
|
||||
],
|
||||
)
|
||||
assert "lease_token" in pick2
|
||||
|
||||
acc = run_cli_json_last(env, ["account", "get", str(aid)])
|
||||
assert acc.get("session_status") == "likely_valid"
|
||||
|
||||
|
||||
class TestStdoutIsolation:
|
||||
def test_stderr_does_not_pollute_stdout_last_line(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(env, ["account", "export-credentials", "--plaintext"])
|
||||
assert proc.returncode == 0
|
||||
assert b"[export] WARNING" in proc.stderr
|
||||
lines = [ln for ln in proc.stdout.decode("utf-8").strip().split("\n") if ln.strip()]
|
||||
payload = json.loads(lines[-1])
|
||||
assert isinstance(payload, list)
|
||||
Reference in New Issue
Block a user