Compare commits

...

19 Commits

Author SHA1 Message Date
91057fa3b7 Release v1.0.56: Credential & Browser Profile Manager
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-05-05 18:51:00 +08:00
6b6d2969c3 release: v1.0.55 SKILL description for routing, CLI mapping table, when-to-use cleanup
All checks were successful
技能自动化发布 / release (push) Successful in 30s
2026-04-12 16:41:54 +08:00
82720ef2fa release: v1.0.54 SKILL/README: add account via chat, auto profile_dir
All checks were successful
技能自动化发布 / release (push) Successful in 30s
2026-04-12 16:08:33 +08:00
3a1ea6d97a Release v1.0.53: account docs split, remove login flow, SCHEMA reference
All checks were successful
技能自动化发布 / release (push) Successful in 37s
2026-04-12 10:56:50 +08:00
75a332e7b8 release: docs, browser_service, runtime_env, SKILL v1.0.52
All checks were successful
技能自动化发布 / release (push) Successful in 28s
2026-04-08 14:00:42 +08:00
4605d3d0e4 release: runtime_env marker, SKILL v1.0.51
All checks were successful
技能自动化发布 / release (push) Successful in 25s
2026-04-08 12:05:14 +08:00
beb95c1415 release: v1.0.50 playwright stealth gemini
All checks were successful
技能自动化发布 / release (push) Successful in 34s
2026-04-07 15:18:14 +08:00
e2eafb7407 feat: remove wait-login; pick-web only; docs sync
All checks were successful
技能自动化发布 / release (push) Successful in 33s
2026-04-07 11:58:27 +08:00
96e5b9ea14 fix: spawn login/open child with python -m for PyArmor runtime path
All checks were successful
技能自动化发布 / release (push) Successful in 31s
2026-04-07 10:58:32 +08:00
97d970dd1b chore: account-manager — runtime_env sibling root, wait-login and browser fixes
All checks were successful
技能自动化发布 / release (push) Successful in 43s
2026-04-07 10:36:40 +08:00
e3da478116 feat: release ZIP keeps scripts/ tree; JIANGCHANG_SKILLS_ROOT and sibling skills root
All checks were successful
技能自动化发布 / release (push) Successful in 31s
2026-04-06 18:38:09 +08:00
ac76f89dc8 feat: wait-login 支持 --force,供搜狐发布等 Cookie 失效仍开窗检测
All checks were successful
技能自动化发布 / release (push) Successful in 31s
Made-with: Cursor
2026-04-06 17:30:50 +08:00
c83841cf90 chore: auto release commit (2026-04-06 16:44:58)
All checks were successful
技能自动化发布 / release (push) Successful in 39s
2026-04-06 16:44:59 +08:00
d938885a49 chore: auto release commit (2026-04-06 14:32:01)
All checks were successful
技能自动化发布 / release (push) Successful in 27s
2026-04-06 14:32:01 +08:00
5afad3285a chore: auto release commit (2026-04-06 12:57:26)
Some checks failed
技能自动化发布 / release (push) Failing after 21s
2026-04-06 12:57:26 +08:00
39daeaca59 chore: auto release commit (2026-04-06 12:10:31)
All checks were successful
技能自动化发布 / release (push) Successful in 13s
2026-04-06 12:10:31 +08:00
c428bedfd5 chore: auto release commit (2026-04-06 10:37:07)
All checks were successful
技能自动化发布 / release (push) Successful in 34s
2026-04-06 10:37:08 +08:00
5082da78ab chore: auto release commit (2026-04-06 10:16:00)
Some checks failed
技能自动化发布 / release (push) Failing after 13s
2026-04-06 10:16:00 +08:00
bfb9c15f13 ci: use kit @main + pass PYARMOR_REG_B64 for licensed CI obfuscation
Some checks failed
技能自动化发布 / release (push) Failing after 12s
Made-with: Cursor
2026-04-05 09:28:58 +08:00
44 changed files with 5641 additions and 2404 deletions

View File

@@ -5,8 +5,9 @@ on:
jobs:
release:
# Pin kit commit so runner does not use a stale @main snapshot (must bump when kit workflow changes).
uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@3ee09bbd73510137436dc44598eee3eb0dd1f820
uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
secrets:
PYARMOR_REG_B64: ${{ secrets.PYARMOR_REG_B64 }}
with:
artifact_platform: windows
pyarmor_platform: windows.x86_64

View File

@@ -1,53 +1,58 @@
---
name: 账号管理
description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系供publisher类Skill调用获取账号信息。
version: 1.0.4
name: 账号与凭据管理
description: "通用 Credential & Browser Profile Manager管理网页账号、RPA 浏览器 profile、LLM/API 平台 API Key。支持物流平台Maersk、COSCO、MSC 等、LLM 平台DeepSeek、豆包等、内容平台。业务 skill 通过 CLI 获取账号,不直接读 SQLite。"
version: 1.0.56
author: 深圳匠厂科技有限公司
metadata:
openclaw:
slug: account-manager
emoji: "👤"
emoji: "🔐"
category: "通用"
allowed-tools:
- bash
---
# 账号管理
# 账号与凭据管理account-manager
## 使用时机
通用 Credential & Browser Profile Manager。在本地 SQLite 管理平台注册、账号身份、凭据元数据API Key/Token 不存明文、RPA 并发锁lease供其它技能通过 CLI 调用 `scripts/main.py` 获取账号信息。
当用户发送以下内容的时候触发本Skill
- 说"初始化账号登录"、"登录搜狐账号"、"帮我登录账号"
- 说"列出账号"、"查看所有账号"、"有哪些账号"
- 说"添加账号"、"删除账号"、"获取账号信息"
## 核心原则
## 执行步骤
1. **account-manager 是唯一账号/凭据管理入口** — 业务 skill 不能自己存账号、密码、API Key、Token。
2. **SQLite 只保存元数据**`secret_ref``secret_mask`,不保存原始 secret 明文。
3. **浏览器登录态** — 使用独立 `profile_dir` 保存 cookie/session不保存网页登录密码。
4. **业务 skill 只通过 CLI/API 拿账号** — 不直接读 SQLite。
### 添加账号(平台用中文名;须填合法中国大陆 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。
2. **登记 API Key/Token 账号**`account add-secret` — 平台 + 凭据类型 + secret 存储方式。
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 并发。
### 登录并自动检测、写入数据库
```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 搜狐号
## 支持的平台
`references/PLATFORMS.md`支持物流、LLM、内容三大业务域。
## 数据与环境
库路径、profile 布局、环境变量见 `references/RUNTIME.md`;表结构见 `references/SCHEMA.md`
## 跨技能集成
`account get/list/pick-web` / `credential pick` 的 stdout 协议见 `references/INTEGRATION.md`
## Secret 存储
- `none`:无 secret浏览器 profile 登录态)
- `env`SQLite 保存环境变量名,运行时从 `os.environ` 读取
- `windows_credential`:正式 Windows 部署,真实 secret 存 Windows Credential Manager
详见 `references/RUNTIME.md`

7
assets/README.md Normal file
View File

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

View File

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

View File

@@ -0,0 +1,20 @@
{
"success": true,
"data": {
"id": 1,
"platform_key": "maersk",
"account_label": "Maersk simulator booking",
"login_id": "demo@maersk.com",
"login_id_type": "email",
"tenant_id": "tenant-demo",
"environment": "simulator",
"role": "booking",
"provider_code": "maersk",
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
"url": "http://localhost:5180/industries/logistics/maersk/login",
"status": "active",
"session_status": "unknown",
"created_at": "2026-05-01T09:00:00",
"updated_at": "2026-05-01T09:00:00"
}
}

View File

@@ -0,0 +1,36 @@
[
{
"id": 1,
"platform_key": "maersk",
"account_label": "Maersk simulator booking",
"login_id": "demo@maersk.com",
"login_id_type": "email",
"tenant_id": "tenant-demo",
"environment": "simulator",
"role": "booking",
"provider_code": "maersk",
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
"url": "http://localhost:5180/industries/logistics/maersk/login",
"status": "active",
"session_status": "unknown",
"created_at": "2026-05-01T09:00:00",
"updated_at": "2026-05-01T09:00:00"
},
{
"id": 2,
"platform_key": "deepseek",
"account_label": "DeepSeek production key",
"login_id": "",
"login_id_type": "unknown",
"tenant_id": "",
"environment": "production",
"role": "api",
"provider_code": "deepseek",
"profile_dir": "",
"url": "https://chat.deepseek.com",
"status": "active",
"session_status": "unknown",
"created_at": "2026-05-01T10:00:00",
"updated_at": "2026-05-01T10:00:00"
}
]

View File

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

160
references/CLI.md Normal file
View File

@@ -0,0 +1,160 @@
# account-manager CLI 速查
`{baseDir}` 替换为技能根目录。所有命令通过 `python {baseDir}/scripts/main.py` 调用(工作目录可为技能根或 `scripts/`,与命令中的路径一致即可)。下文示例简写为 `python main.py` 表示在 `scripts/` 下执行。
## 子命令与实现(与代码一致)
### 平台管理
| CLI | 说明 |
|-----|------|
| `platform list [--domain logistics\|llm\|content]` | 列出平台注册 |
| `platform get <platform>` | 获取平台详情 |
### 账号管理
| CLI | 说明 |
|-----|------|
| `account add-web --platform <p> --login-id <id> [...]` | 创建网页/RPA 账号 |
| `account add-secret --platform <p> --credential-type <type> [...]` | 创建 API Key/Token 凭据 |
| `account get <id> [--with-credentials]` | 获取单条账号 JSON |
| `account list [--platform <p>] [--environment <env>] [--tenant-id <t>] [--role <role>]` | 列出账号 JSON 数组 |
| `account pick-web --platform <p> [--lease] [--ttl-sec 900]` | 挑选网页自动化候选 |
| `account mark-session <id> --session-status <status>` | 标记登录状态 |
| `account mark-error <id> --code <code> --message <msg>` | 标记错误 |
| `account mark-used <id>` | 标记已使用 |
| `account open <id>` | 打开持久化浏览器 profile仅查看 |
### 凭据管理
| CLI | 说明 |
|-----|------|
| `credential pick --platform <p> [--type <type>] [--reveal]` | 挑选凭据(默认隐藏 secret |
| `credential resolve --id <credential_id> [--reveal]` | 按 ID 解析凭据 |
### 租约管理
| CLI | 说明 |
|-----|------|
| `lease release <lease_token>` | 释放租约 |
| `lease list [--active]` | 列出租约 |
| `lease cleanup` | 清理过期租约 |
### 浏览器
| CLI | 说明 |
|-----|------|
| `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>` |
| `list [platform\|all]` | 人类可读列表 |
| `add <platform> <phone>` | `account add-web`phone 作为 login_id |
| `delete id\|platform <...>` | 删除账号 |
---
## account add-web
```bash
python main.py account add-web \
--platform maersk \
--login-id demo@maersk.com \
--login-id-type email \
--label "Maersk simulator booking" \
--tenant-id tenant-demo \
--environment simulator \
--role booking \
--provider-code maersk \
--url http://localhost:5180/industries/logistics/maersk/login
```
- `--platform`(必填):平台键/别名
- `--login-id`登录标识email/username/phone/customer_code
- `--login-id-type`:默认 `unknown`
- `--label`:人可读名称,不填则自动拼接
- `--tenant-id`:租户/项目标识
- `--environment`:默认 `production`
- `--role`:默认 `default`
- `--provider-code`:默认取平台 provider_code
- `--url`:默认取平台 default_url
- `--extra-json`:扩展 JSON
- `--profile-dir`:高级调试用,不推荐
输出 JSON包含 `id``profile_dir``url` 等。
## account add-secret
```bash
# env 方式
python main.py account add-secret \
--platform deepseek \
--credential-type api_key \
--label "DeepSeek env key" \
--secret-storage env \
--secret-ref DEEPSEEK_API_KEY \
--environment production \
--role api
# windows_credential 方式
echo "sk-test-1234" | python main.py account add-secret \
--platform deepseek \
--credential-type api_key \
--label "DeepSeek production key" \
--secret-storage windows_credential \
--secret-stdin \
--environment production \
--role api
```
- `--secret-storage``env` / `windows_credential` / `none`
- `--secret-stdin`:从 stdin 读取 secret仅 windows_credential
- `--secret-ref`:环境变量名(仅 env
- `none` 仅允许 `credential_type=browser_profile``note`
输出 JSON包含 `account_id`(未指定 `--account-id` 时会自动创建一条用于挂载凭据的账号记录)、`credential_id``secret_mask``secret_ref`**不包含 secret 明文**。
## account pick-web
```bash
python main.py account pick-web \
--platform maersk \
--environment simulator \
--tenant-id tenant-demo \
--role booking \
--lease \
--ttl-sec 900 \
--holder monitor-competitor-rates \
--purpose maersk_sim_rpa
```
- 筛选 status=active 且未被 active lease 占用的账号
- 优先 session_status != expired
- 优先 last_used_at 为空的账号
- `--lease`:创建 lease返回 `lease_token``lease_expires_at`
- `--ttl-sec`:租约时长,默认 900 秒
## credential pick
成功时输出单行 JSON`{"success": true, ...fields }`;失败时为 [ERRORS.md](ERRORS.md) 的机器 JSON。
```bash
# 默认不返回 secret
python main.py credential pick --platform deepseek --type api_key --environment production --role api
# --reveal 才返回 secret 明文
python main.py credential pick --platform deepseek --type api_key --environment production --role api --reveal
```
## 通用说明
- **平台参数**:可为 `scripts/util/platforms.py` 中配置的**展示名、别名或英文键**(如 `sohu``搜狐号``maersk``马士基`)。详见 [PLATFORMS.md](PLATFORMS.md)。
- **数据与路径**库文件、profile 目录由环境变量决定。详见 [RUNTIME.md](RUNTIME.md)。
- **机器可读命令**:成功时输出单行 JSON失败时首行 `ERROR:`。详见 [INTEGRATION.md](INTEGRATION.md)。
- **错误码**:见 [ERRORS.md](ERRORS.md)。

79
references/ERRORS.md Normal file
View File

@@ -0,0 +1,79 @@
# 终端错误码account-manager
约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:` 开头;多行说明可忽略或仅作展示。
## 新增机器 JSON 失败格式
```json
{
"success": false,
"error": {
"code": "ERROR:...",
"message": "..."
}
}
```
## ERROR:CLI — CLI 参数 / 路由
| 前缀 | 含义 |
|------|------|
| `ERROR:CLI_ADD_MISSING_ARGS` | `add` 参数不足 |
| `ERROR:CLI_GET_MISSING_ID` | `get` 缺少 id |
| `ERROR:CLI_OPEN_MISSING_ID` | `open` 缺少 id |
| `ERROR:CLI_DELETE_MISSING_ARGS` | `delete` 参数不足 |
| `ERROR:CLI_DELETE_BAD_MODE` | `delete` 第二段不是 `id`/`platform` |
| `ERROR:CLI_LIST_JSON_MISSING_PLATFORM` | `list-json` 缺少平台参数 |
| `ERROR:CLI_PICK_WEB_MISSING_ARGS` | `pick-web` 缺少平台 |
| `ERROR:CLI_BAD_ARGS` | 子命令参数缺失或非法 |
| `ERROR:CLI_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 |
## ERROR: — 数据库
| 前缀 | 含义 |
|------|------|
| `ERROR:DB_ERROR` | SQLite 写入失败等 |
## ERROR: — 平台与账号
| 前缀 | 含义 |
|------|------|
| `ERROR:INVALID_PLATFORM` | 平台名无法识别 |
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果 |
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id 不存在 |
| `ERROR:ACCOUNT_DISABLED` | 账号状态为 disabled |
| `ERROR:NO_ACCOUNT` | `pick-web` 无符合条件的可用账号(含:均被 active 未过期 lease 占用时) |
## ERROR: — 租约
| 前缀 | 含义 |
|------|------|
| `ERROR:LEASE_CONFLICT` | 同账号已有 active 未过期 lease |
| `ERROR:LEASE_NOT_FOUND` | 未找到活跃租约 |
| `ERROR:LEASE_EXPIRED` | 租约已过期 |
## ERROR: — 凭据与 Secret
| 前缀 | 含义 |
|------|------|
| `ERROR:CREDENTIAL_NOT_FOUND` | 凭据不存在 |
| `ERROR:CREDENTIAL_DISABLED` | 凭据不可用 |
| `ERROR:SECRET_STORAGE_UNSUPPORTED` | 不支持的 secret 存储方式 |
| `ERROR:SECRET_REF_MISSING` | env 存储缺少环境变量名 |
| `ERROR:SECRET_READ_FAILED` | 读取 secret 失败 |
| `ERROR:SECRET_WRITE_FAILED` | 写入 secret 失败 |
| `ERROR:SECRET_ENV_MISSING` | 环境变量未设置 |
| `ERROR:WINDOWS_CREDENTIAL_UNAVAILABLE` | 非 Windows 平台不支持 |
## ERROR: — 浏览器 / 环境
| 前缀 | 含义 |
|------|------|
| `ERROR:需要 playwright Python 包` | 未安装 `playwright` 包或本机无 Chrome/Edge |
| `ERROR:PROFILE_DIR_MISSING` | 账号记录中 `profile_dir` 为空 |
## 非前缀输出
- **`get` / `list-json` / `pick-web`** 成功时:`stdout` 应为**单行 JSON**。
- 失败时首行 `ERROR:`,可能有后续说明行。
- **新增机器命令建议统一 JSON 输出**,旧兼容命令可继续输出 `ERROR:` 文本前缀。

94
references/INTEGRATION.md Normal file
View File

@@ -0,0 +1,94 @@
# 与其它 Skill 集成account-manager
## 核心约束
业务 skill **不能自己存账号、密码、API Key、Token**。必须通过 account-manager CLI 获取。
## 推荐调用方式
使用 **`subprocess`** 调用 `scripts/main.py`,工作目录可为技能根或 `scripts/`。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`** 必须与最终用户会话一致。
## 机器可读命令摘要
| 目的 | 命令 | 成功时 stdout |
|------|------|----------------|
| 取单条账号 JSON | `account get <id>` | 单行 JSON 对象 |
| 批量账号 JSON | `account list --platform <p> [--environment <e>]` | 单行 JSON 数组 |
| 网页自动化候选 | `account pick-web --platform <p> [--lease]` | 单行 JSON 对象 |
| 获取 API Key | `credential pick --platform <p> --type api_key [--reveal]` | 单行 JSON`success: true` + 字段) |
| 兼容入口 | `get <id>` / `list-json <p>` / `pick-web <p>` | 同上 |
## 解析协议
1. 读取子进程 **stdout** 首行。
2. 若首行以 **`ERROR:`** 开头 → 失败。
3. 否则对首行做 `json.loads`
## JSON 对象字段
### account get / pick-web
```json
{
"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",
"last_used_at": "2026-05-01T10:00:00",
"created_at": "2026-05-01T09:00:00",
"updated_at": "2026-05-01T09:00:00"
}
```
pick-web 额外字段(带 lease 时):
- `lease_token`: 租约 token
- `lease_expires_at`: 过期时间
### 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` 字段。业务 skill 不要向最终用户展示 reveal 输出。**
失败时使用统一 JSON`{"success": false, "error": {"code": "ERROR:...", "message": "..."}}`(见 [ERRORS.md](ERRORS.md))。
## 业务流程示例
### 物流 RPA
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 调用
1. `credential pick --platform deepseek --type api_key --environment production --reveal`
2. 解析 JSON → 取 `secret` 字段
3. 在业务进程内存中使用,不写文件、不打日志
## 退出码
请以 **stdout 首行前缀****JSON 可解析性** 为准;不要仅依赖退出码。

50
references/PLATFORMS.md Normal file
View File

@@ -0,0 +1,50 @@
# 平台与别名account-manager
**权威来源**`scripts/util/platforms.py` 中的 `PLATFORMS`。下表便于文档阅读;若与代码不一致,**以代码为准**。
## 物流平台
| 英文键 | 展示名 | 供应商代码 | 别名 |
|--------|--------|-----------|------|
| `maersk` | Maersk | maersk | 马士基 |
| `cosco` | COSCO | cosco | 中远海运 |
| `msc` | MSC | msc | 地中海航运 |
| `cma_cgm` | CMA CGM | cma_cgm | 达飞 |
| `evergreen` | Evergreen | evergreen | 长荣 |
| `cargo_wise` | CargoWise | cargo_wise | Cargo Wise |
| `freightos` | Freightos | freightos | — |
| `webcargo` | WebCargo | webcargo | Web Cargo |
| `sinotrans` | Sinotrans | sinotrans | 中外运 |
## LLM / AI 平台
| 英文键 | 展示名 | 别名 | 默认 URL |
|--------|--------|------|----------|
| `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、Google Gemini、Bard | https://gemini.google.com |
| `minimax` | MiniMax | — | — |
## 内容平台
| 英文键 | 展示名 | 别名 | 默认 URL |
|--------|--------|------|----------|
| `sohu` | 搜狐号 | 搜狐 | https://mp.sohu.com |
| `toutiao` | 头条号 | 头条 | https://mp.toutiao.com/ |
| `zhihu` | 知乎 | 知乎号 | https://www.zhihu.com |
| `wechat` | 微信公众号 | 公众号、微信 | https://mp.weixin.qq.com |
## 解析规则
- 英文键本身、各 `display_name`、各 `aliases` 条目(及小写形式)均可解析到对应键。
- 不区分大小写。
- 无法识别时输出 `ERROR:INVALID_PLATFORM`,并附带支持的平台列表。
- 解析函数:`resolve_platform_key(name)` — 支持 key、display_name、aliases。
## 平台注册到数据库
`seed_platforms(conn)` 在每次 `init_db()` 后自动执行,将 `PLATFORMS` 字典 upsert 到 `platforms` 表。

53
references/README.md Normal file
View File

@@ -0,0 +1,53 @@
---
description: "通用 Credential & Browser Profile Manager管理网页账号、RPA 浏览器 profile、LLM/API 平台 API Key。支持物流、LLM、内容三大业务域。"
---
# 🔐 账号与凭据管理
把你在各平台的账号与凭据**登记到本地**,后续物流 RPA、大模型写作、内容发布等技能才能自动选用对应环境。
> 本技能是**唯一**账号/凭据管理入口。业务 skill 不自己存密码、API Key而是通过 CLI 调用获取。
## 它能帮你做什么
- **添加网页/RPA 账号** — 选平台,给出登录标识(邮箱/手机/用户名),自动创建浏览器 profile 目录。
- **添加 API Key/Token** — 安全存储API Key 明文存入 Windows Credential Manager 或环境变量引用SQLite 只保存元数据和打码值。
- **查看/列出账号** — 按平台、环境、角色等多维度筛选。
- **给 RPA 技能用** — `pick-web` 按规则挑候选账号,支持 lease 防并发。
- **给 LLM 技能用** — `credential pick` 安全获取 API Key。
- **在浏览器里查看** — `open <id>` 打开 Chrome/Edge 查看,仅查看不写库。
## 你可以这样对它说
- 「帮我添加一个 Maersk 模拟器账号,邮箱 demo@maersk.com。」
- 「添加 DeepSeek 的 API Key存在 Windows 凭据管理器里。」
- 「列出所有物流平台的账号。」
- 「用浏览器打开 3 号账号看看页面。」
- 「给 RPA 技能取一个 maersk 模拟器的 booking 账号。」
## 你需要提供什么
| 场景 | 你需要给的 |
|---|---|
| 添加网页账号 | **平台名称** + **登录标识**(邮箱/手机/用户名) |
| 添加 API Key | **平台名称** + **凭据类型**api_key 等)+ **secret**(通过 stdin 或环境变量) |
| 查看/列出 | 可选:平台名、环境、角色等筛选条件 |
| 浏览器打开 | 账号 **id**(需本机已安装 Chrome 或 Edge |
## 它不做什么
- 不在此处判定各网站「是否已登录」(由业务技能自行处理)
- 不代替内容管理、发布、大模型调用等业务技能
- 不保存 API Key 明文在 SQLite 中(仅存引用和打码值)
## Secret 存储方式
| 方式 | 说明 |
|---|---|
| `none` | 无 secret浏览器 profile 登录态) |
| `env` | 引用环境变量名,运行时读取 |
| `windows_credential` | 正式推荐:真实 secret 存 Windows Credential Manager |
---
**作者**:深圳匠厂科技有限公司 · **版本**:1.0.55

59
references/RUNTIME.md Normal file
View File

@@ -0,0 +1,59 @@
# 运行环境与数据路径account-manager
## 数据落盘位置
`get_data_root()` / `get_user_id()` 来自 **`scripts/jiangchang_skill_core/runtime_env.py`**`scripts/util/runtime_paths.py` 在此基础上拼技能子目录。
- **数据根目录**:优先 **`JIANGCHANG_DATA_ROOT`**未设置且未走本地注入时Windows 默认 `D:\jiangchang-data`,其它系统 `~/.jiangchang-data`
- **用户/工作空间 id**:优先 **`JIANGCHANG_USER_ID`**;未设置时为 **`_anon`**。
- **技能数据目录**`{数据根}/{用户id}/account-manager/`
- **SQLite**`account-manager.db`
- **Profile**`profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix}]/`
- **日志**:与其它技能共用 `{数据根}/{用户id}/logs/jiangchang.log`
## 调试为什么「list 没数据」
- 若终端与网关注入的 `JIANGCHANG_*` 不一致,会写到**不同用户目录**。
- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`** 后,每次 CLI 命令前在 **stderr** 打印实际路径。
## 本地 CLI 默认值
各技能 `main.py` 调用 `apply_cli_local_defaults()`
- `CLI_LOCAL_DEV_ENABLED = True` 时,未设置环境变量则注入平台默认值。
- 发版前改为 `False` 或依赖宿主注入。
## Secret 存储
| 方式 | 说明 | 适用场景 |
|------|------|----------|
| `none` | 不存 secret | 浏览器 profile 登录态 |
| `env` | SQLite 只存环境变量名,运行时从 `os.environ` 读取 | 轻量测试 |
| `windows_credential` | 真实 secret 存 Windows Credential Manager | Windows 本地正式部署 |
- **不把 secret 明文写入 SQLite**
- **不在日志中记录 secret 明文**
- 只有 `credential pick --reveal``credential resolve --reveal` 才返回 secret
## 日志
| 变量 | 作用 |
|------|------|
| `JIANGCHANG_LOG_LEVEL` | 日志级别,默认 `INFO` |
| `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 等时WARNING 及以上同步 stderr |
| `JIANGCHANG_LOG_FILE` | 覆盖主日志文件绝对路径 |
| `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` |
| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入 |
## 浏览器open
- 本机已安装 Chrome 或 Edge
- Python 环境需 `playwright` 包(匠厂共享 runtime 已包含)
- 使用 **channel 模式**`channel=chrome|msedge`**不下载 Chromium**
- **不要执行 `playwright install chromium`**
### 关键浏览器参数
- `headless=False`
- `no_viewport=True`
- `args` 包含 `--start-maximized`
- stealth 模式可选(`OPENCLAW_PLAYWRIGHT_STEALTH` 环境变量控制)

122
references/SCHEMA.md Normal file
View File

@@ -0,0 +1,122 @@
# 数据库表结构account-manager v2
## 数据库路径
`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/account-manager/account-manager.db`
路径解析细节见 [RUNTIME.md](RUNTIME.md)。
## Schema 版本
`PRAGMA user_version = 2`
## 1. platforms — 平台注册表
| 字段 | 类型 | 说明 |
|------|------|------|
| `platform_key` | TEXT PK | 平台唯一键maersk / deepseek / sohu 等) |
| `display_name` | TEXT NOT NULL | 展示名Maersk / DeepSeek / 搜狐号) |
| `domain` | TEXT NOT NULL | 业务域logistics / llm / content / ecommerce / generic |
| `provider_code` | TEXT | 供应商代码,物流 profile 可复用 |
| `default_url` | TEXT | 默认入口 URL |
| `aliases_json` | TEXT | 别名 JSON 数组 |
| `capabilities_json` | TEXT | 能力声明 JSON`{"web":true,"api_key":true,"rpa":true}` |
| `enabled` | INTEGER | 是否启用1=yes 0=no |
| `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 | 登录标识email/phone/username/customer_code可空 |
| `login_id_type` | TEXT | email / username / phone / customer_code / api_account / unknown |
| `tenant_id` | TEXT | 租户/项目标识,可空 |
| `environment` | TEXT | simulator / staging / production / test |
| `role` | TEXT | booking / tracking / admin / sales / api / default |
| `provider_code` | TEXT | 冗余供应商码 |
| `profile_dir` | TEXT | Playwright 持久化浏览器 profile 目录 |
| `url` | TEXT | 账号默认入口 URL |
| `status` | TEXT | active / disabled / needs_review |
| `session_status` | TEXT | unknown / likely_valid / needs_login / expired |
| `last_used_at` | INTEGER | 最近 pick/使用时间 |
| `last_login_check_at` | INTEGER | 最近登录检查时间 |
| `last_error_code` | TEXT | 最近错误代码 |
| `last_error_message` | TEXT | 最近错误消息 |
| `extra_json` | TEXT | 扩展字段 JSON |
| `created_at` | INTEGER | Unix 秒 UTC |
| `updated_at` | INTEGER | Unix 秒 UTC |
**索引:** `idx_accounts_platform`, `idx_accounts_pick_web`, `idx_accounts_provider`, `idx_accounts_status`
**唯一约束说明:** `(platform_key, login_id, tenant_id, environment, role)` 通过应用层检查,因为 SQLite 允许多条 NULL login_id。
## 3. credentials — 凭据API Key / Token / Password
**核心原则SQLite 只保存元数据,不保存 secret 明文。**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INTEGER PK AUTO | 主键 |
| `account_id` | INTEGER | 关联 accounts.id可空平台级 API Key |
| `platform_key` | TEXT NOT NULL | 平台唯一键 |
| `credential_label` | TEXT NOT NULL | 人可读名称 |
| `credential_type` | TEXT | api_key / password / bearer_token / oauth_client / refresh_token / browser_profile / note |
| `secret_storage` | TEXT | none / env / windows_credential |
| `secret_ref` | TEXT | env 时为环境变量名windows_credential 时为 target name |
| `secret_mask` | TEXT | 打码展示sk-****abcd不可反推 |
| `expires_at` | INTEGER | 过期时间 |
| `status` | TEXT | active / disabled / expired / needs_rotation |
| `last_used_at` | INTEGER | 最近使用时间 |
| `extra_json` | TEXT | 扩展字段 JSON |
| `created_at` | INTEGER | Unix 秒 UTC |
| `updated_at` | INTEGER | Unix 秒 UTC |
**索引:** `idx_credentials_platform_type`, `idx_credentials_account`, `idx_credentials_status`
## 4. account_leases — RPA 并发锁
防止多个任务同时打开同一个 profile_dir。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INTEGER PK AUTO | 主键 |
| `account_id` | INTEGER NOT NULL | 关联 accounts.id |
| `lease_token` | TEXT UNIQUE | 随机 token用于 release/heartbeat |
| `holder` | TEXT NOT NULL | 持有者标识 |
| `purpose` | TEXT | 用途说明 |
| `expires_at` | INTEGER NOT NULL | 过期时间 |
| `heartbeat_at` | INTEGER | 最近心跳时间 |
| `released_at` | INTEGER | 主动释放时间 |
| `status` | TEXT | active / released / expired |
| `created_at` | INTEGER | Unix 秒 UTC |
| `updated_at` | INTEGER | Unix 秒 UTC |
**索引:** `idx_leases_account_status`, `idx_leases_token`, `idx_leases_expires`
**约束:**
- acquire 前先把过期 active lease 标记为 expired
- 同一 account_id 同时只能有一个 active 未过期 lease
- release 必须校验 lease_token
## Profile 文件布局
```
{DATA_ROOT}/{USER_ID}/account-manager/
├── account-manager.db
└── profiles/
└── {domain}/
└── {platform_key}/
└── {safe_label}[_{login_id_suffix}]/
```
删除账号时实现会尽量同时删除对应目录。
## 与 JSON 输出的对应关系
`account get/list/pick-web``credential pick` 的 JSON 输出字段与上表一一对应。详见 [INTEGRATION.md](INTEGRATION.md) 与 `assets/schemas/`

View File

@@ -1,721 +0,0 @@
import sys
import json
import os
import re
import sqlite3
from urllib.parse import urlparse
import subprocess
import tempfile
import time
from datetime import datetime
from typing import Optional
# Windows GBK 编码兼容修复
if sys.platform == "win32":
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SKILL_SLUG = "account-manager"
# 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, -- 平台标识,与内置 PLATFORM_URLS 键一致,如 sohu
phone TEXT, -- 可选绑定手机号
profile_dir TEXT, -- Playwright 持久化用户数据目录(绝对路径)
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
);
"""
def _now_unix() -> int:
return int(time.time())
def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]:
"""对外 JSON本地时区 ISO8601 字符串,便于人读。"""
if ts is None:
return None
try:
return datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds")
except (ValueError, OSError, OverflowError):
return None
PLATFORM_URLS = {
"sohu": "https://mp.sohu.com",
"zhihu": "https://www.zhihu.com",
"wechat": "https://mp.weixin.qq.com",
"kimi": "https://kimi.moonshot.cn",
"deepseek": "https://chat.deepseek.com",
"doubao": "https://www.doubao.com",
"qianwen": "https://tongyi.aliyun.com",
"yiyan": "https://yiyan.baidu.com",
"yuanbao": "https://yuanbao.tencent.com",
}
# 新账号默认名称搜狐1号 / sohu_2 等同平台序号
_PLATFORM_NAME_CN = {
"sohu": "搜狐",
"zhihu": "知乎",
"wechat": "微信",
"kimi": "Kimi",
"deepseek": "DeepSeek",
"doubao": "豆包",
"qianwen": "通义",
"yiyan": "文心",
"yuanbao": "元宝",
}
def get_data_root():
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
if env:
return env
if sys.platform == "win32":
return r"D:\jiangchang-data"
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
def get_user_id():
uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip()
return uid or "_anon"
def get_skill_data_dir():
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
os.makedirs(path, exist_ok=True)
return path
def get_db_path():
return os.path.join(get_skill_data_dir(), "account-manager.db")
def get_default_profile_dir(account_id):
path = os.path.join(get_skill_data_dir(), "profiles", str(account_id))
os.makedirs(path, exist_ok=True)
return path
def get_conn():
return sqlite3.connect(get_db_path())
def init_db():
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'")
if not cur.fetchone():
cur.executescript(ACCOUNTS_TABLE_SQL)
conn.commit()
finally:
conn.close()
def _normalize_account_id(account_id):
if account_id is None:
return None
s = str(account_id).strip()
if s.isdigit():
return int(s)
return s
def get_account_by_id(account_id):
init_db()
aid = _normalize_account_id(account_id)
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT id, name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json,
created_at, updated_at
FROM accounts WHERE id = ?
""",
(aid,),
)
row = cur.fetchone()
if not row:
return None
acc = {
"id": row[0],
"name": row[1],
"platform": row[2],
"phone": row[3] or "",
"profile_dir": row[4] or get_default_profile_dir(row[0]),
"url": row[5] or PLATFORM_URLS.get(row[2], ""),
"login_status": int(row[6] or 0),
"last_login_at": _unix_to_iso_local(row[7]),
"created_at": _unix_to_iso_local(row[9]),
"updated_at": _unix_to_iso_local(row[10]),
}
if row[8]:
try:
extra = json.loads(row[8])
if isinstance(extra, dict):
acc.update(extra)
except Exception:
pass
return acc
finally:
conn.close()
def _default_name_for_platform(platform: str, index: int) -> str:
cn = _PLATFORM_NAME_CN.get(platform)
if cn:
return f"{cn}{index}"
return f"{platform}_{index}"
def cmd_list(platform="all"):
init_db()
conn = get_conn()
try:
cur = conn.cursor()
if platform == "all":
cur.execute(
"SELECT id, name, phone, platform, login_status FROM accounts ORDER BY platform, id"
)
else:
cur.execute(
"SELECT id, name, phone, platform, login_status FROM accounts WHERE platform = ? ORDER BY id",
(platform,),
)
rows = cur.fetchall()
finally:
conn.close()
found = False
for row in rows:
phone = row[2] or "未绑定手机"
login_text = "已登录" if int(row[4] or 0) == 1 else "未登录"
print(f"账号ID:{row[0]} | 名称:{row[1]} | 手机号:{phone} | 平台:{row[3]} | 状态:{login_text}")
found = True
if not found:
print("ERROR:NO_ACCOUNTS_FOUND")
def cmd_get(account_id):
acc = get_account_by_id(account_id)
if acc:
print(json.dumps(acc, ensure_ascii=False))
return
print("ERROR:ACCOUNT_NOT_FOUND")
def cmd_add(platform: str, phone: str = ""):
"""添加账号:仅平台 + 可选手机号ID 自增,名称按平台序号自动生成。"""
init_db()
platform = (platform or "").strip().lower()
if platform not in PLATFORM_URLS:
print(f"ERROR:INVALID_PLATFORM (支持: {', '.join(sorted(PLATFORM_URLS.keys()))})")
return
phone = (phone or "").strip()
url = PLATFORM_URLS[platform]
now = _now_unix()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM accounts WHERE platform = ?", (platform,))
next_idx = cur.fetchone()[0] + 1
name = _default_name_for_platform(platform, next_idx)
cur.execute(
"""
INSERT INTO accounts (name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json, created_at, updated_at)
VALUES (?, ?, ?, '', ?, 0, NULL, NULL, ?, ?)
""",
(name, platform, phone, url, now, now),
)
new_id = cur.lastrowid
profile_dir = get_default_profile_dir(new_id)
cur.execute(
"UPDATE accounts SET profile_dir = ?, updated_at = ? WHERE id = ?",
(profile_dir, _now_unix(), new_id),
)
conn.commit()
finally:
conn.close()
print(f"✅ 已保存账号ID {new_id} | {name} | {platform}")
print(f" 登录请执行python account.py login {new_id}")
def _win_find_exe(candidates):
for p in candidates:
if p and os.path.isfile(p):
return p
return None
def resolve_chromium_channel():
"""
Playwright channel: 'chrome' | 'msedge' | None
Windows 优先 Chrome其次 Edge其它系统默认 chrome由 Playwright 解析 PATH
"""
if sys.platform != "win32":
return "chrome"
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
local = os.environ.get("LocalAppData", "")
chrome = _win_find_exe(
[
os.path.join(pf, "Google", "Chrome", "Application", "chrome.exe"),
os.path.join(pfx86, "Google", "Chrome", "Application", "chrome.exe"),
os.path.join(local, "Google", "Chrome", "Application", "chrome.exe") if local else "",
]
)
if chrome:
return "chrome"
edge = _win_find_exe(
[
os.path.join(pfx86, "Microsoft", "Edge", "Application", "msedge.exe"),
os.path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
os.path.join(local, "Microsoft", "Edge", "Application", "msedge.exe") if local else "",
]
)
if edge:
return "msedge"
return None
def _print_browser_install_hint():
print("❌ 未检测到 Google Chrome 或 Microsoft Edge请先安装后再登录")
print(" • Chrome: https://www.google.com/chrome/")
print(" • Edge: https://www.microsoft.com/zh-cn/edge")
def _login_timeout_seconds():
try:
return max(60, int((os.getenv("JIANGCHANG_LOGIN_TIMEOUT_SECONDS") or "300").strip()))
except ValueError:
return 300
def _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 _definitely_logged_out_url(page_url: str) -> bool:
"""
通用:是否「明显仍在登录/认证页」。
用路径段判断,避免 mpfe/v4/login 因含 mpfe 被误判为已登录。
"""
if not page_url or not page_url.strip():
return True
try:
p = urlparse(page_url.strip())
except Exception:
return True
host = (p.netloc or "").lower()
path = (p.path or "").lower()
query = (p.query or "").lower()
if "passport" in host or "login." in host or "signin." in host:
return True
seg = [s for s in path.split("/") if s]
login_segments = (
"login",
"signin",
"signup",
"register",
"authorize",
"authentication",
)
if any(s in login_segments for s in seg):
return True
if re.search(r"/(login|signin)(/|$|\?)", path):
return True
if "redirect_uri" in query and ("login" in query or "passport" in query):
return True
return False
def _sohu_url_logged_in(page_url: str) -> bool:
"""
搜狐:仅白名单路径视为已进入后台(不用裸 mpfe避免 /mpfe/v4/login 误判)。
"""
if _definitely_logged_out_url(page_url):
return False
try:
path = (urlparse(page_url).path or "").lower()
except Exception:
return False
if "contentmanagement" in path:
return True
if "/mpfe/v4/main" in path or path.rstrip("/").endswith("/main"):
return True
if "/mpfe/v4/publish" in path or "/mpfe/v4/data" in path:
return True
return False
def _sohu_page_shows_login_form(page) -> bool:
"""搜狐登录页常见密码框;可见则认为未登录(与 URL 交叉验证)。"""
try:
loc = page.locator('input[type="password"]')
n = loc.count()
if n == 0:
return False
return loc.first.is_visible(timeout=800)
except Exception:
return False
def _url_looks_logged_in(platform: str, page_url: str) -> bool:
"""是否判定为已登录:搜狐用白名单+负例;其它平台仅用「非明确登录页」宽松策略。"""
if not page_url or not page_url.strip():
return False
if platform == "sohu":
return _sohu_url_logged_in(page_url)
if _definitely_logged_out_url(page_url):
return False
u = page_url.lower()
if "passport" in u or "signin" in u:
return False
return True
def _verify_logged_in_page(platform: str, page) -> bool:
"""结合 URL 与页面(搜狐加验登录表单)。"""
url = page.url or ""
if platform == "sohu":
if _sohu_page_shows_login_form(page):
return False
return _sohu_url_logged_in(url)
return _url_looks_logged_in(platform, url)
def _verify_login(profile_dir: str, platform: str, start_url: str, channel: str) -> bool:
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("⚠️ 未安装 playwright跳过登录校验请: pip install playwright && playwright install")
return False
try:
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
user_data_dir=profile_dir,
headless=True,
channel=channel,
args=["--disable-blink-features=AutomationControlled"],
)
try:
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(start_url, wait_until="domcontentloaded", timeout=45000)
for _ in range(20):
if _verify_logged_in_page(platform, page):
return True
time.sleep(1.0)
return _verify_logged_in_page(platform, page)
finally:
ctx.close()
except Exception:
return False
def cmd_login(account_id):
target = get_account_by_id(account_id)
if not target:
print("ERROR:ACCOUNT_NOT_FOUND")
return
channel = resolve_chromium_channel()
if not channel:
_print_browser_install_hint()
return
try:
import playwright # noqa: F401
except ImportError:
print("ERROR:需要 playwrightpip install playwright && playwright install chromium")
return
profile_dir = target["profile_dir"]
os.makedirs(profile_dir, exist_ok=True)
url = (target.get("url") or "").strip() or PLATFORM_URLS.get(target["platform"], "https://www.google.com")
platform = (target.get("platform") or "").strip().lower()
timeout_sec = _login_timeout_seconds()
poll_sec = _login_poll_interval_seconds()
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
print(f"正在为账号 [{target['name']}] 打开 {browser_name}")
print(f"访问地址:{url}")
print(
"请在窗口内完成登录。系统会轮询地址栏,进入后台后将自动关闭窗口并写入状态(无需手动关浏览器)。"
)
print(f"轮询间隔约 {poll_sec} 秒;全程最长 {timeout_sec} 秒。")
# 子进程:搜狐轮询 URL+页面;勿用「含 mpfe 即登录」,/mpfe/v4/login 会误判。
runner = r"""import json, sys, time
from urllib.parse import urlparse
from playwright.sync_api import sync_playwright
def sohu_logged_in(url):
u = (url or "").strip()
if not u:
return False
try:
p = urlparse(u)
except Exception:
return False
path = (p.path or "").lower()
host = (p.netloc or "").lower()
if "passport" in host:
return False
segs = [s for s in path.split("/") if s]
if any(s in ("login", "signin", "signup", "register", "authorize") for s in segs):
return False
if "contentmanagement" in path:
return True
if "/mpfe/v4/main" in path or path.rstrip("/").endswith("/main"):
return True
if "/mpfe/v4/publish" in path or "/mpfe/v4/data" in path:
return True
return False
def sohu_not_login_page(page):
try:
loc = page.locator('input[type="password"]')
if loc.count() == 0:
return False
return loc.first.is_visible(timeout=600)
except Exception:
return False
with open(sys.argv[1], encoding="utf-8") as f:
c = json.load(f)
platform = (c.get("platform") or "").lower()
poll = float(c.get("poll_interval", 1.5))
deadline = time.time() + float(c["timeout_sec"])
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 platform == "sohu":
while time.time() < deadline:
try:
if sohu_logged_in(page.url) and not sohu_not_login_page(page):
break
except Exception:
break
time.sleep(poll)
else:
rem = max(0.0, deadline - time.time())
try:
ctx.wait_for_event("close", timeout=rem * 1000)
except Exception:
pass
except Exception as e:
print(e, file=sys.stderr)
raise
finally:
try:
ctx.close()
except Exception:
pass
"""
cfg = {
"channel": channel,
"profile_dir": profile_dir,
"url": url,
"timeout_sec": timeout_sec,
"platform": platform,
"poll_interval": poll_sec,
}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8"
) as jf:
json.dump(cfg, jf, ensure_ascii=False)
cfg_path = jf.name
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, encoding="utf-8"
) as pf:
pf.write(runner)
py_path = pf.name
try:
r = subprocess.run(
[sys.executable, py_path, cfg_path],
timeout=timeout_sec + 180,
)
if r.returncode != 0:
print("⚠️ 浏览器进程异常退出,仍将尝试检测登录状态")
except subprocess.TimeoutExpired:
print("⚠️ 等待浏览器超时,仍将尝试检测登录状态")
finally:
try:
os.unlink(cfg_path)
except OSError:
pass
try:
os.unlink(py_path)
except OSError:
pass
time.sleep(1.5)
print("正在检测登录状态…")
ok = _verify_login(profile_dir, platform, url, channel)
_mark_login_status(target["id"], ok)
if ok:
print("✅ 检测到已登录,状态已写入数据库")
else:
print("⚠️ 未检测到有效登录,状态为未登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。")
def cmd_open(account_id):
"""打开该账号的持久化浏览器,仅用于肉眼确认是否已登录;不写数据库。"""
target = get_account_by_id(account_id)
if not target:
print("ERROR:ACCOUNT_NOT_FOUND")
return
channel = resolve_chromium_channel()
if not channel:
_print_browser_install_hint()
return
try:
import playwright # noqa: F401
except ImportError:
print("ERROR:需要 playwrightpip install playwright && playwright install chromium")
return
profile_dir = target["profile_dir"]
os.makedirs(profile_dir, exist_ok=True)
url = (target.get("url") or "").strip() or PLATFORM_URLS.get(
target["platform"], "https://www.google.com"
)
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
print(f"正在打开 [{target['name']}] 的 {browser_name}(仅查看,不写入数据库)")
print(f"地址:{url}")
print("请在本窗口中自行确认登录态。关闭浏览器后命令结束。")
print("需要自动检测并写入数据库时请执行python account.py login <id>")
runner_open = r"""import json, sys
from playwright.sync_api import sync_playwright
with open(sys.argv[1], encoding="utf-8") as f:
c = json.load(f)
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
user_data_dir=c["profile_dir"],
headless=False,
channel=c["channel"],
no_viewport=True,
args=["--start-maximized"],
)
try:
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(c["url"], wait_until="domcontentloaded", timeout=60000)
ctx.wait_for_event("close", timeout=86400000)
except Exception as e:
print(e, file=sys.stderr)
raise
finally:
try:
ctx.close()
except Exception:
pass
"""
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8"
) as jf:
json.dump(cfg, jf, ensure_ascii=False)
cfg_path = jf.name
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, encoding="utf-8"
) as pf:
pf.write(runner_open)
py_path = pf.name
try:
subprocess.run([sys.executable, py_path, cfg_path])
finally:
try:
os.unlink(cfg_path)
except OSError:
pass
try:
os.unlink(py_path)
except OSError:
pass
def _mark_login_status(account_id, success: bool):
now = _now_unix()
conn = get_conn()
try:
cur = conn.cursor()
if success:
cur.execute(
"UPDATE accounts SET login_status = 1, last_login_at = ?, updated_at = ? WHERE id = ?",
(now, now, account_id),
)
else:
cur.execute(
"UPDATE accounts SET login_status = 0, updated_at = ? WHERE id = ?",
(now, account_id),
)
conn.commit()
finally:
conn.close()
if __name__ == "__main__":
if len(sys.argv) < 2:
print(
"用法python account.py add <platform> [phone] | list [platform] | get <id> | open <id> | login <id>"
)
sys.exit(1)
cmd = sys.argv[1]
if len(sys.argv) >= 3 and sys.argv[2] == "list" and cmd in PLATFORM_URLS:
cmd_list(cmd)
elif cmd in PLATFORM_URLS and len(sys.argv) == 2:
cmd_list(cmd)
elif cmd == "add" and len(sys.argv) >= 3:
plat = sys.argv[2].lower()
phone_arg = sys.argv[3] if len(sys.argv) >= 4 else ""
cmd_add(plat, phone_arg)
elif cmd == "list":
cmd_list(sys.argv[2] if len(sys.argv) >= 3 else "all")
elif cmd == "get" and len(sys.argv) >= 3:
cmd_get(sys.argv[2])
elif cmd == "open" and len(sys.argv) >= 3:
cmd_open(sys.argv[2])
elif cmd == "login" and len(sys.argv) >= 3:
cmd_login(sys.argv[2])
else:
print("参数错误")
sys.exit(1)

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

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

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

@@ -0,0 +1,471 @@
# -*- coding: utf-8 -*-
"""
CLI entry point: argv dispatch for account-manager v2.
Subcommand tree:
platform list/get
account add-web|add-secret|get|list|pick-web|mark-session|mark-error|mark-used
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 service.account_service import (
cmd_account_add_secret,
cmd_account_add_web,
cmd_account_get,
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_get,
cmd_platform_list,
)
from service.browser_service import cmd_open
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
from util.logging_config import get_skill_logger, setup_skill_logging
from util.platforms import _platform_list_cn_for_help, resolve_platform_key
from util.runtime_paths import _maybe_print_paths_debug_cli
# =========================================================================
# Help text
# =========================================================================
_USAGE = """account-manager v2 — Credential & Browser Profile Manager
平台管理:
platform list [--domain logistics|llm|content]
platform get <platform>
账号管理:
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>]
account add-secret --platform <p> --credential-type <type>
--secret-storage env|windows_credential|none
[--secret-ref <env_var>] [--secret-stdin]
[--label <label>] [--account-id <id>]
[--environment <env>] [--role <role>]
account get <id> [--with-credentials]
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>
凭据管理:
credential pick --platform <p> [--type <type>] [--environment <env>]
[--role <role>] [--reveal]
credential resolve --id <credential_id> [--reveal]
租约管理:
lease release <lease_token>
lease list [--active]
lease cleanup
浏览器:
account open <id>
open <id> # 兼容别名
兼容入口(旧版):
get <id>
list-json <platform|all> [--limit N]
pick-web <platform>
list [platform|all]
add <platform> <phone>
delete id|platform <...>
"""
# =========================================================================
# Argument parsing helpers
# =========================================================================
def _get_opt(argv: list[str], flag: str, default=None):
"""Get value of --flag from argv, returning default if not found."""
if flag in argv:
idx = argv.index(flag)
if idx + 1 < len(argv):
return argv[idx + 1]
return default
def _has_flag(argv: list[str], flag: str) -> bool:
return flag in argv
def _get_int_opt(argv: list[str], flag: str, default: int) -> int:
v = _get_opt(argv, flag)
if v is not None:
try:
return int(v)
except ValueError:
return default
return default
# =========================================================================
# Main dispatch
# =========================================================================
def main() -> None:
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
log = get_skill_logger()
if len(sys.argv) < 2:
print(_USAGE)
sys.exit(1)
log.info("cli_start argv=%s", sys.argv)
av = sys.argv
cmd = av[1]
_maybe_print_paths_debug_cli()
# Determine if first arg is a platform key (for legacy shorthand)
plat_from_first = resolve_platform_key(cmd)
# ---- platform subcommand ----
if cmd == "platform":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS platform 需要子命令list | get")
sys.exit(1)
sub = av[2]
if sub == "list":
domain = _get_opt(av, "--domain")
cmd_platform_list(domain)
elif sub == "get":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS platform get 需要 <platform>。")
sys.exit(1)
cmd_platform_get(av[3])
else:
print("ERROR:CLI_BAD_ARGS platform 子命令必须是 list 或 get。")
sys.exit(1)
# ---- account subcommand ----
elif cmd == "account":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS account 需要子命令add-web / get / …)。")
print(_USAGE)
sys.exit(1)
sub = av[2]
if sub == "add-web":
_cmd_add_web(av)
elif sub == "add-secret":
_cmd_add_secret(av)
elif sub == "get":
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])
else:
print("ERROR:CLI_BAD_ARGS 未知的 account 子命令。")
print(_USAGE)
sys.exit(1)
# ---- credential subcommand ----
elif cmd == "credential":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS credential 需要子命令pick | resolve")
sys.exit(1)
sub = av[2]
if sub == "pick":
_cmd_cred_pick(av)
elif sub == "resolve":
_cmd_cred_resolve(av)
else:
print("ERROR:CLI_BAD_ARGS credential 子命令必须是 pick 或 resolve。")
sys.exit(1)
# ---- lease subcommand ----
elif cmd == "lease":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS lease 需要子命令release | list | cleanup")
sys.exit(1)
sub = av[2]
if sub == "release":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS lease release 需要 <lease_token>。")
sys.exit(1)
cmd_lease_release(av[3])
elif sub == "list":
active = _has_flag(av, "--active")
cmd_lease_list(active)
elif sub == "cleanup":
cmd_lease_cleanup()
else:
print("ERROR:CLI_BAD_ARGS lease 子命令必须是 release、list 或 cleanup。")
sys.exit(1)
# ---- Legacy aliases ----
elif cmd == "get":
if len(sys.argv) < 3:
print("ERROR:CLI_GET_MISSING_ID")
sys.exit(1)
cmd_get(sys.argv[2])
elif cmd == "list-json":
if len(sys.argv) < 3:
print("ERROR:CLI_LIST_JSON_MISSING_PLATFORM")
sys.exit(1)
av = sys.argv[2:]
platform_arg = av[0]
lim = 200
if "--limit" in av:
i = av.index("--limit")
if i + 1 < len(av):
try:
lim = int(av[i + 1])
except ValueError:
pass
cmd_list_json(platform_arg, limit=lim)
elif cmd == "pick-web":
if len(sys.argv) < 3:
print("ERROR:CLI_PICK_WEB_MISSING_ARGS")
sys.exit(1)
cmd_pick_web(sys.argv[2])
elif cmd == "open":
if len(sys.argv) < 3:
print("ERROR:CLI_OPEN_MISSING_ID")
sys.exit(1)
cmd_open(sys.argv[2])
elif cmd == "list":
platform_arg = sys.argv[2] if len(sys.argv) >= 3 else "all"
cmd_list(platform_arg)
elif cmd == "add":
if len(sys.argv) < 4:
print("ERROR:CLI_ADD_MISSING_ARGS")
sys.exit(1)
# Legacy add maps to add-web for backward compat
cmd_account_add_web(
platform_input=sys.argv[2],
login_id=sys.argv[3],
login_id_type="phone",
label=None,
tenant_id=None,
environment="production",
role="default",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
elif cmd == "delete":
if len(sys.argv) < 4:
print("ERROR:CLI_DELETE_MISSING_ARGS")
sys.exit(1)
mode = (sys.argv[2] or "").strip().lower()
if mode == "id":
cmd_delete_by_id(sys.argv[3])
elif mode == "platform":
if len(sys.argv) >= 5:
cmd_delete_by_platform_phone(sys.argv[3], sys.argv[4])
else:
cmd_delete_by_platform(sys.argv[3])
else:
print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]}」。")
sys.exit(1)
# ---- Platform shorthand: "python main.py <platform>" ----
elif plat_from_first:
cmd_list(sys.argv[1])
# ---- Unknown ----
else:
print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别。")
print()
print(_USAGE)
sys.exit(1)
# =========================================================================
# Sub-parsers for complex commands
# =========================================================================
def _cmd_add_web(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
login_id = _get_opt(argv, "--login-id")
login_id_type = _get_opt(argv, "--login-id-type", "unknown")
label = _get_opt(argv, "--label")
tenant_id = _get_opt(argv, "--tenant-id")
environment = _get_opt(argv, "--environment", "production")
role = _get_opt(argv, "--role", "default")
provider_code = _get_opt(argv, "--provider-code")
url = _get_opt(argv, "--url")
extra_json_str = _get_opt(argv, "--extra-json")
profile_dir_override = _get_opt(argv, "--profile-dir")
if not plat:
print("ERROR:CLI_BAD_ARGS account add-web 需要 --platform <platform>。")
return
cmd_account_add_web(
platform_input=plat,
login_id=login_id,
login_id_type=login_id_type,
label=label,
tenant_id=tenant_id,
environment=environment,
role=role,
provider_code=provider_code,
url=url,
extra_json_str=extra_json_str,
profile_dir_override=profile_dir_override,
)
def _cmd_add_secret(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
ctype = _get_opt(argv, "--credential-type", "api_key")
label = _get_opt(argv, "--label")
storage = _get_opt(argv, "--secret-storage", "env")
stdin_mode = _has_flag(argv, "--secret-stdin")
sref = _get_opt(argv, "--secret-ref")
aid = _get_int_opt(argv, "--account-id", 0) or None
env = _get_opt(argv, "--environment", "production")
role = _get_opt(argv, "--role", "api")
extra_json_str = _get_opt(argv, "--extra-json")
if not plat:
print("ERROR:CLI_BAD_ARGS account add-secret 需要 --platform <platform>。")
return
cmd_account_add_secret(
platform_input=plat,
credential_type=ctype,
label=label,
secret_storage=storage,
secret_stdin=stdin_mode,
secret_ref=sref,
account_id=aid,
environment=env,
role=role,
extra_json_str=extra_json_str,
)
def _cmd_account_list(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform")
env = _get_opt(argv, "--environment")
tenant = _get_opt(argv, "--tenant-id")
role = _get_opt(argv, "--role")
limit = _get_int_opt(argv, "--limit", 200)
cmd_account_list(
platform_input=plat,
environment=env,
tenant_id=tenant,
role=role,
limit=limit,
)
def _cmd_pick_web_new(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
env = _get_opt(argv, "--environment")
tenant = _get_opt(argv, "--tenant-id")
role = _get_opt(argv, "--role")
do_lease = _has_flag(argv, "--lease")
ttl = _get_int_opt(argv, "--ttl-sec", 900)
holder = _get_opt(argv, "--holder")
purpose = _get_opt(argv, "--purpose")
if not plat:
print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform <platform>。")
return
cmd_account_pick_web(
platform_input=plat,
environment=env,
tenant_id=tenant,
role=role,
do_lease=do_lease,
ttl_sec=ttl,
holder=holder,
purpose=purpose,
)
def _cmd_cred_pick(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
ctype = _get_opt(argv, "--type")
env = _get_opt(argv, "--environment")
role = _get_opt(argv, "--role")
reveal = _has_flag(argv, "--reveal")
if not plat:
print("ERROR:CLI_BAD_ARGS credential pick 需要 --platform <platform>。")
return
cmd_credential_pick(
platform_input=plat,
credential_type=ctype,
environment=env,
role=role,
reveal=reveal,
)
def _cmd_cred_resolve(argv: list[str]) -> None:
cid = _get_int_opt(argv, "--id", 0)
reveal = _has_flag(argv, "--reveal")
if cid <= 0:
print("ERROR:CLI_BAD_ARGS credential resolve 需要 --id <credential_id>。")
return
cmd_credential_resolve(cid, reveal=reveal)
if __name__ == "__main__":
main()

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

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

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

@@ -0,0 +1,667 @@
# -*- coding: utf-8 -*-
"""
Accounts, Credentials, Leases, and Platforms CRUD (no stdout).
All functions operate on a connection (passed or opened locally).
Time fields are INTEGER Unix seconds (UTC).
"""
import json
import time
from datetime import datetime
from typing import Any, Optional
from db.connection import get_conn, init_db
from util.logging_config import get_skill_logger
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _now_unix() -> int:
return int(time.time())
def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]:
"""Convert Unix timestamp to local ISO8601 string, or None."""
if ts is None:
return None
try:
return datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds")
except (ValueError, OSError, OverflowError):
return None
def _acc_row_to_dict(row) -> dict[str, Any]:
"""Convert an accounts table row tuple to a dict matching the v2 schema."""
return {
"id": row[0],
"platform_key": row[1],
"account_label": row[2],
"login_id": row[3] or "",
"login_id_type": row[4],
"tenant_id": row[5] or "",
"environment": row[6],
"role": row[7],
"provider_code": row[8] or "",
"profile_dir": (row[9] or "").strip(),
"url": row[10] or "",
"status": row[11],
"session_status": row[12],
"last_used_at": _unix_to_iso_local(row[13]),
"last_login_check_at": _unix_to_iso_local(row[14]),
"last_error_code": row[15] or "",
"last_error_message": row[16] or "",
"extra_json": _safe_json_loads(row[17]),
"created_at": _unix_to_iso_local(row[18]),
"updated_at": _unix_to_iso_local(row[19]),
}
# ============================================================================
# Platforms
# ============================================================================
def platform_exists(conn, platform_key: str) -> bool:
cur = conn.cursor()
cur.execute("SELECT 1 FROM platforms WHERE platform_key = ?", (platform_key,))
return cur.fetchone() is not None
def list_platforms_from_db(conn, domain: Optional[str] = None) -> list[dict]:
cur = conn.cursor()
if domain:
cur.execute(
"SELECT platform_key, display_name, domain, provider_code, "
"default_url, aliases_json, capabilities_json, enabled, "
"created_at, updated_at FROM platforms WHERE domain = ? ORDER BY platform_key",
(domain,),
)
else:
cur.execute(
"SELECT platform_key, display_name, domain, provider_code, "
"default_url, aliases_json, capabilities_json, enabled, "
"created_at, updated_at FROM platforms ORDER BY platform_key"
)
rows = cur.fetchall()
result = []
for r in rows:
result.append({
"platform_key": r[0],
"display_name": r[1],
"domain": r[2],
"provider_code": r[3] or "",
"default_url": r[4] or "",
"aliases": _safe_json_loads(r[5], []),
"capabilities": _safe_json_loads(r[6], {}),
"enabled": bool(r[7]),
"created_at": _unix_to_iso_local(r[8]),
"updated_at": _unix_to_iso_local(r[9]),
})
return result
def get_platform_from_db(conn, platform_key: str) -> Optional[dict]:
cur = conn.cursor()
cur.execute(
"SELECT platform_key, display_name, domain, provider_code, "
"default_url, aliases_json, capabilities_json, enabled, "
"created_at, updated_at FROM platforms WHERE platform_key = ?",
(platform_key,),
)
r = cur.fetchone()
if not r:
return None
return {
"platform_key": r[0],
"display_name": r[1],
"domain": r[2],
"provider_code": r[3] or "",
"default_url": r[4] or "",
"aliases": _safe_json_loads(r[5], []),
"capabilities": _safe_json_loads(r[6], {}),
"enabled": bool(r[7]),
"created_at": _unix_to_iso_local(r[8]),
"updated_at": _unix_to_iso_local(r[9]),
}
# ============================================================================
# Accounts — CRUD
# ============================================================================
def insert_account(
conn,
platform_key: str,
account_label: str,
login_id: Optional[str],
login_id_type: str,
tenant_id: Optional[str],
environment: str,
role: str,
provider_code: Optional[str],
profile_dir: Optional[str],
url: Optional[str],
extra_json: Optional[str],
now: int,
) -> int:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO accounts
(platform_key, account_label, login_id, login_id_type, tenant_id,
environment, role, provider_code, profile_dir, url,
status, session_status, extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'unknown', ?, ?, ?)
""",
(
platform_key, account_label, login_id, login_id_type, tenant_id,
environment, role, provider_code, profile_dir, url,
extra_json or "{}", now, now,
),
)
return int(cur.lastrowid)
def get_account_by_id(account_id) -> Optional[dict]:
"""Get a single account by id. Returns dict or None."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
cur.execute(
"""
SELECT id, platform_key, account_label, login_id, login_id_type,
tenant_id, environment, role, provider_code,
profile_dir, url, status, session_status,
last_used_at, last_login_check_at,
last_error_code, last_error_message, extra_json,
created_at, updated_at
FROM accounts WHERE id = ?
""",
(int(account_id),),
)
row = cur.fetchone()
if not row:
return None
return _acc_row_to_dict(row)
finally:
conn.close()
def find_accounts(
platform_key: Optional[str] = None,
environment: Optional[str] = None,
tenant_id: Optional[str] = None,
role: Optional[str] = None,
status: Optional[str] = None,
provider_code: Optional[str] = None,
limit: int = 200,
) -> list[dict]:
"""Find accounts by optional filters. Returns list of dicts."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
conditions = []
params = []
if platform_key:
conditions.append("platform_key = ?")
params.append(platform_key)
if environment:
conditions.append("environment = ?")
params.append(environment)
if tenant_id:
conditions.append("tenant_id = ?")
params.append(tenant_id)
if role:
conditions.append("role = ?")
params.append(role)
if status:
conditions.append("status = ?")
params.append(status)
if provider_code:
conditions.append("provider_code = ?")
params.append(provider_code)
where = ""
if conditions:
where = "WHERE " + " AND ".join(conditions)
sql = (
"SELECT id, platform_key, account_label, login_id, login_id_type, "
"tenant_id, environment, role, provider_code, profile_dir, url, "
"status, session_status, last_used_at, last_login_check_at, "
"last_error_code, last_error_message, extra_json, created_at, updated_at "
f"FROM accounts {where} ORDER BY updated_at DESC, id DESC LIMIT ?"
)
params.append(int(limit))
cur.execute(sql, params)
return [_acc_row_to_dict(row) for row in cur.fetchall()]
finally:
conn.close()
def find_account_ids(
platform_key: str,
environment: Optional[str] = None,
tenant_id: Optional[str] = None,
role: Optional[str] = None,
) -> list[int]:
"""Return account IDs matching filters, excluding those with active leases."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
conditions = ["a.status = 'active'", "a.platform_key = ?"]
params = [platform_key]
if environment:
conditions.append("a.environment = ?")
params.append(environment)
if tenant_id:
conditions.append("a.tenant_id = ?")
params.append(tenant_id)
if role:
conditions.append("a.role = ?")
params.append(role)
# Exclude accounts with active non-expired leases
now = _now_unix()
conditions.append(
"a.id NOT IN (SELECT account_id FROM account_leases "
"WHERE status = 'active' AND expires_at > ?)"
)
params.append(now)
where = " AND ".join(conditions)
cur.execute(
f"SELECT a.id FROM accounts a WHERE {where} "
"ORDER BY CASE WHEN a.session_status = 'expired' THEN 1 ELSE 0 END ASC, "
"a.last_used_at IS NULL DESC, a.updated_at DESC, a.id DESC",
params,
)
return [int(row[0]) for row in cur.fetchall()]
finally:
conn.close()
def update_account_last_used(account_id: int) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE accounts SET last_used_at = ?, updated_at = ? WHERE id = ?",
(_now_unix(), _now_unix(), account_id),
)
conn.commit()
finally:
conn.close()
def update_account_session_status(account_id: int, session_status: str) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE accounts SET session_status = ?, last_login_check_at = ?, updated_at = ? WHERE id = ?",
(session_status, _now_unix(), _now_unix(), account_id),
)
conn.commit()
finally:
conn.close()
def update_account_error(
account_id: int,
error_code: str,
error_message: str,
) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE accounts SET last_error_code = ?, last_error_message = ?, updated_at = ? WHERE id = ?",
(error_code, error_message, _now_unix(), account_id),
)
conn.commit()
finally:
conn.close()
def account_exists_with_login_id(conn, platform_key: str, login_id: str, environment: str, role: str) -> bool:
cur = conn.cursor()
cur.execute(
"SELECT id FROM accounts WHERE platform_key = ? AND login_id = ? AND environment = ? AND role = ?",
(platform_key, login_id, environment, role),
)
return cur.fetchone() is not None
# ============================================================================
# Credentials
# ============================================================================
def insert_credential(
conn,
account_id: Optional[int],
platform_key: str,
credential_label: str,
credential_type: str,
secret_storage: str,
secret_ref: Optional[str],
secret_mask: Optional[str],
expires_at: Optional[int],
extra_json: Optional[str],
now: int,
) -> int:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO credentials
(account_id, platform_key, credential_label, credential_type,
secret_storage, secret_ref, secret_mask, expires_at, status,
extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
""",
(
account_id, platform_key, credential_label, credential_type,
secret_storage, secret_ref, secret_mask, expires_at,
extra_json or "{}", now, now,
),
)
return int(cur.lastrowid)
def get_credential_by_id(credential_id: int) -> Optional[dict]:
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
cur.execute(
"SELECT id, account_id, platform_key, credential_label, credential_type, "
"secret_storage, secret_ref, secret_mask, expires_at, status, last_used_at, "
"extra_json, created_at, updated_at FROM credentials WHERE id = ?",
(credential_id,),
)
row = cur.fetchone()
if not row:
return None
return {
"id": row[0],
"account_id": row[1],
"platform_key": row[2],
"credential_label": row[3],
"credential_type": row[4],
"secret_storage": row[5],
"secret_ref": row[6] or "",
"secret_mask": row[7] or "",
"expires_at": _unix_to_iso_local(row[8]),
"status": row[9],
"last_used_at": _unix_to_iso_local(row[10]),
"created_at": _unix_to_iso_local(row[12]),
"updated_at": _unix_to_iso_local(row[13]),
}
finally:
conn.close()
def find_credentials(
platform_key: str,
credential_type: Optional[str] = None,
environment: Optional[str] = None,
role: Optional[str] = None,
status: str = "active",
limit: int = 50,
) -> list[dict]:
"""Find credentials, optionally joining accounts for environment/role filter."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
conditions = ["c.status = ?"]
params = [status]
conditions.append("c.platform_key = ?")
params.append(platform_key)
if credential_type:
conditions.append("c.credential_type = ?")
params.append(credential_type)
# If environment/role filters provided, join accounts table
if environment or role:
conditions.append("c.account_id = a.id")
if environment:
conditions.append("a.environment = ?")
params.append(environment)
if role:
conditions.append("a.role = ?")
params.append(role)
where = " AND ".join(conditions)
cur.execute(
f"SELECT c.id, c.account_id, c.platform_key, c.credential_label, "
f"c.credential_type, c.secret_storage, c.secret_ref, c.secret_mask, "
f"c.expires_at, c.status, c.last_used_at, c.extra_json, "
f"c.created_at, c.updated_at "
f"FROM credentials c, accounts a WHERE {where} "
f"ORDER BY c.last_used_at IS NULL DESC, c.updated_at DESC, c.id DESC "
f"LIMIT ?",
params + [int(limit)],
)
else:
where = " AND ".join(conditions)
cur.execute(
f"SELECT c.id, c.account_id, c.platform_key, c.credential_label, "
f"c.credential_type, c.secret_storage, c.secret_ref, c.secret_mask, "
f"c.expires_at, c.status, c.last_used_at, c.extra_json, "
f"c.created_at, c.updated_at "
f"FROM credentials c WHERE {where} "
f"ORDER BY c.last_used_at IS NULL DESC, c.updated_at DESC, c.id DESC "
f"LIMIT ?",
params + [int(limit)],
)
rows = cur.fetchall()
result = []
for row in rows:
result.append({
"id": row[0],
"account_id": row[1],
"platform_key": row[2],
"credential_label": row[3],
"credential_type": row[4],
"secret_storage": row[5],
"secret_ref": row[6] or "",
"secret_mask": row[7] or "",
"expires_at": _unix_to_iso_local(row[8]),
"status": row[9],
"last_used_at": _unix_to_iso_local(row[10]),
"created_at": _unix_to_iso_local(row[12]),
"updated_at": _unix_to_iso_local(row[13]),
})
return result
finally:
conn.close()
def update_credential_last_used(credential_id: int) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE credentials SET last_used_at = ?, updated_at = ? WHERE id = ?",
(_now_unix(), _now_unix(), credential_id),
)
conn.commit()
finally:
conn.close()
# ============================================================================
# Leases
# ============================================================================
def acquire_lease(
account_id: int,
lease_token: str,
holder: str,
purpose: Optional[str],
ttl_sec: int,
) -> dict:
"""
Acquire a lease for an account. Expires active leases first.
Returns the lease record dict on success.
Raises ValueError if a non-expired active lease already exists.
"""
log = get_skill_logger()
log.info("lease_acquire_attempt account_id=%s holder=%s purpose=%s ttl=%s", account_id, holder, purpose, ttl_sec)
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
now = _now_unix()
# Mark any expired active leases as expired
cur.execute(
"UPDATE account_leases SET status = 'expired', updated_at = ? "
"WHERE account_id = ? AND status = 'active' AND expires_at <= ?",
(now, account_id, now),
)
if cur.rowcount > 0:
log.info("lease_expired_cleanup account_id=%s count=%s", account_id, cur.rowcount)
# Check for existing active non-expired lease
cur.execute(
"SELECT id, lease_token, holder FROM account_leases "
"WHERE account_id = ? AND status = 'active' AND expires_at > ?",
(account_id, now),
)
existing = cur.fetchone()
if existing:
log.warning(
"lease_conflict account_id=%s existing_holder=%s existing_token_prefix=%s",
account_id, existing[2], existing[1][:8],
)
raise ValueError(
f"Account {account_id} already has an active lease "
f"(holder={existing[2]}, token_prefix={existing[1][:8]}) (LEASE_CONFLICT)"
)
expires_at = now + max(1, int(ttl_sec))
cur.execute(
"""
INSERT INTO account_leases
(account_id, lease_token, holder, purpose, expires_at, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
""",
(account_id, lease_token, holder, purpose, expires_at, now, now),
)
conn.commit()
lease_id = int(cur.lastrowid)
log.info("lease_acquire_success account_id=%s lease_id=%s token_prefix=%s", account_id, lease_id, lease_token[:8])
return {
"id": lease_id,
"account_id": account_id,
"lease_token": lease_token,
"holder": holder,
"purpose": purpose,
"expires_at": _unix_to_iso_local(expires_at),
"status": "active",
"created_at": _unix_to_iso_local(now),
}
finally:
conn.close()
def release_lease(lease_token: str) -> bool:
"""Release a lease by token. Returns True if found and released."""
log = get_skill_logger()
log.info("lease_release_attempt token_prefix=%s", lease_token[:8])
conn = get_conn()
try:
cur = conn.cursor()
now = _now_unix()
cur.execute(
"UPDATE account_leases SET status = 'released', released_at = ?, updated_at = ? "
"WHERE lease_token = ? AND status = 'active'",
(now, now, lease_token),
)
conn.commit()
if cur.rowcount == 0:
log.warning("lease_release_not_found token_prefix=%s", lease_token[:8])
return False
log.info("lease_release_success token_prefix=%s", lease_token[:8])
return True
finally:
conn.close()
def list_active_leases() -> list[dict]:
"""List all active (non-expired, non-released) leases."""
conn = get_conn()
try:
cur = conn.cursor()
now = _now_unix()
cur.execute(
"SELECT id, account_id, lease_token, holder, purpose, expires_at, "
"heartbeat_at, released_at, status, created_at, updated_at "
"FROM account_leases WHERE status = 'active' AND expires_at > ? "
"ORDER BY created_at DESC",
(now,),
)
rows = cur.fetchall()
result = []
for r in rows:
result.append({
"id": r[0],
"account_id": r[1],
"lease_token": r[2],
"holder": r[3],
"purpose": r[4] or "",
"expires_at": _unix_to_iso_local(r[5]),
"heartbeat_at": _unix_to_iso_local(r[6]),
"released_at": _unix_to_iso_local(r[7]),
"status": r[8],
"created_at": _unix_to_iso_local(r[9]),
"updated_at": _unix_to_iso_local(r[10]),
})
return result
finally:
conn.close()
def cleanup_expired_leases() -> int:
"""Mark all expired active leases as expired. Returns count cleaned."""
log = get_skill_logger()
conn = get_conn()
try:
cur = conn.cursor()
now = _now_unix()
cur.execute(
"UPDATE account_leases SET status = 'expired', updated_at = ? "
"WHERE status = 'active' AND expires_at <= ?",
(now, now),
)
count = cur.rowcount
conn.commit()
if count > 0:
log.info("lease_cleanup_expired count=%s", count)
return count
finally:
conn.close()
# ============================================================================
# Internal helpers
# ============================================================================
def _safe_json_loads(val: Any, default: Any = None) -> Any:
if val is None:
return default
try:
return json.loads(val) if isinstance(val, str) else val
except (json.JSONDecodeError, TypeError):
return default

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

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

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

@@ -0,0 +1,145 @@
# -*- 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 migration).
"""
import textwrap
# ---------------------------------------------------------------------------
# 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)
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
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
secret_ref TEXT, -- env 时为环境变量名windows_credential 时为 target namenone 时为空
secret_mask TEXT, -- 打码展示,例如 sk-****abcd不能反推出原文
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_account ON credentials(account_id);
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);
""")
# ---------------------------------------------------------------------------
# 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_VERSION = 2

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
# -*- coding: utf-8 -*-
"""
Chromium/Edge browser profile opener.
Uses system Chrome/Edge channel, Playwright launch_persistent_context.
Does NOT download Playwright Chromium.
Does NOT check login status.
Does NOT write secret to SQLite.
"""
import json
import os
import subprocess
import sys
import tempfile
from db.connection import init_db
from db.accounts_repo import get_account_by_id
from util.logging_config import (
get_skill_logger,
subprocess_env_with_trace,
)
from util.platforms import PLATFORMS
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
_SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR)
def _win_find_exe(candidates):
for p in candidates:
if p and os.path.isfile(p):
return p
return None
def resolve_chromium_channel():
"""
Playwright channel: 'chrome' | 'msedge' | None
Windows 优先 Chrome其次 Edge其它系统默认 chrome由 Playwright 解析 PATH
"""
if sys.platform != "win32":
return "chrome"
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
local = os.environ.get("LocalAppData", "")
chrome = _win_find_exe(
[
os.path.join(pf, "Google", "Chrome", "Application", "chrome.exe"),
os.path.join(pfx86, "Google", "Chrome", "Application", "chrome.exe"),
os.path.join(local, "Google", "Chrome", "Application", "chrome.exe") if local else "",
]
)
if chrome:
return "chrome"
edge = _win_find_exe(
[
os.path.join(pfx86, "Microsoft", "Edge", "Application", "msedge.exe"),
os.path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
os.path.join(local, "Microsoft", "Edge", "Application", "msedge.exe") if local else "",
]
)
if edge:
return "msedge"
return None
def _print_browser_install_hint():
print("❌ 未检测到 Google Chrome 或 Microsoft Edge请先安装")
print(" • Chrome: https://www.google.com/chrome/")
print(" • Edge: https://www.microsoft.com/zh-cn/edge")
def cmd_open(account_id):
"""
打开该账号的持久化浏览器,仅用于肉眼核对。
不写数据库、不判定是否已登录。
"""
log = get_skill_logger()
init_db()
log.info("browser_open_start account_id=%r", account_id)
target = get_account_by_id(account_id)
if not target:
log.warning("browser_open_error account_id=%r not_found", account_id)
print("ERROR:ACCOUNT_NOT_FOUND")
return
channel = resolve_chromium_channel()
if not channel:
_print_browser_install_hint()
return
try:
import playwright # noqa: F401
except ImportError:
print(
"ERROR:需要 playwright Python 包。"
)
return
profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir:
log.warning("browser_open_error account_id=%s profile_dir_missing", account_id)
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
return
os.makedirs(profile_dir, exist_ok=True)
# Use account url, fall back to platform default
platform_spec = PLATFORMS.get(target["platform_key"], {})
url = (target.get("url") or "").strip() or platform_spec.get("default_url", "")
log.info(
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s",
profile_dir,
url,
channel,
target.get("platform_key"),
)
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
print(f"正在打开 [{target['account_label']}] 的 {browser_name}(仅查看,不写入数据库)")
print(f"地址:{url}")
print("本命令不做登录检测。")
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8"
) as jf:
json.dump(cfg, jf, ensure_ascii=False)
cfg_path = jf.name
try:
subprocess.run(
[sys.executable, "-m", "service.open_child_runner", cfg_path],
cwd=_SCRIPTS_ROOT,
env=subprocess_env_with_trace(),
)
log.info("browser_open_close account_id=%s channel=%s", account_id, channel)
except Exception as e:
log.error("browser_open_error account_id=%s error=%s", account_id, str(e))
raise
finally:
try:
os.unlink(cfg_path)
except OSError:
pass

View File

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

View File

@@ -0,0 +1,306 @@
# -*- 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
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}")

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

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

View File

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

View File

@@ -0,0 +1,21 @@
"""Re-export unified logging (implementation: jiangchang_skill_core.unified_logging)."""
from jiangchang_skill_core.unified_logging import (
attach_unified_file_handler,
ensure_trace_for_process,
get_skill_log_file_path,
get_skill_logger,
get_unified_logs_dir,
setup_skill_logging,
subprocess_env_with_trace,
)
__all__ = [
"attach_unified_file_handler",
"ensure_trace_for_process",
"get_skill_log_file_path",
"get_skill_logger",
"get_unified_logs_dir",
"setup_skill_logging",
"subprocess_env_with_trace",
]

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

@@ -0,0 +1,341 @@
# -*- coding: utf-8 -*-
"""
Platform definitions and resolution.
Central authoritative registry for all platforms (logistics, LLM, content, etc.).
Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms().
"""
import json
import time
from typing import Any, Optional
from util.logging_config import get_skill_logger
# ---------------------------------------------------------------------------
# Platform specification fields:
# display_name Human-readable name (e.g. "Maersk")
# domain Business domain: logistics / llm / content / ecommerce / generic
# provider_code Supplier code (logistics); can be same as key
# default_url Default entry URL (empty string if none)
# aliases List of name aliases (Chinese/English, for CLI resolution)
# capabilities Dict of capability flags:
# {"web": bool, "api_key": bool, "rpa": bool}
# ---------------------------------------------------------------------------
PLATFORMS: dict[str, dict[str, Any]] = {
# =========================================================================
# Logistics platforms
# =========================================================================
"maersk": {
"display_name": "Maersk",
"domain": "logistics",
"provider_code": "maersk",
"default_url": "https://www.maersk.com",
"aliases": ["马士基", "maersk", "Maersk"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"cosco": {
"display_name": "COSCO",
"domain": "logistics",
"provider_code": "cosco",
"default_url": "",
"aliases": ["中远海运", "COSCO", "cosco"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"msc": {
"display_name": "MSC",
"domain": "logistics",
"provider_code": "msc",
"default_url": "",
"aliases": ["MSC", "地中海航运"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"cma_cgm": {
"display_name": "CMA CGM",
"domain": "logistics",
"provider_code": "cma_cgm",
"default_url": "",
"aliases": ["达飞", "CMA CGM", "cma cgm"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"evergreen": {
"display_name": "Evergreen",
"domain": "logistics",
"provider_code": "evergreen",
"default_url": "",
"aliases": ["长荣", "Evergreen", "evergreen"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"cargo_wise": {
"display_name": "CargoWise",
"domain": "logistics",
"provider_code": "cargo_wise",
"default_url": "",
"aliases": ["CargoWise", "Cargo Wise", "cargo wise"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"freightos": {
"display_name": "Freightos",
"domain": "logistics",
"provider_code": "freightos",
"default_url": "",
"aliases": ["Freightos", "freightos"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"webcargo": {
"display_name": "WebCargo",
"domain": "logistics",
"provider_code": "webcargo",
"default_url": "",
"aliases": ["WebCargo", "web cargo"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"sinotrans": {
"display_name": "Sinotrans",
"domain": "logistics",
"provider_code": "sinotrans",
"default_url": "",
"aliases": ["中外运", "Sinotrans", "sinotrans"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
# =========================================================================
# LLM / AI platforms
# =========================================================================
"deepseek": {
"display_name": "DeepSeek",
"domain": "llm",
"provider_code": "deepseek",
"default_url": "https://chat.deepseek.com",
"aliases": ["DeepSeek", "deepseek"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"doubao": {
"display_name": "Doubao",
"domain": "llm",
"provider_code": "doubao",
"default_url": "https://www.doubao.com",
"aliases": ["豆包", "Doubao"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"kimi": {
"display_name": "Kimi",
"domain": "llm",
"provider_code": "kimi",
"default_url": "https://kimi.moonshot.cn",
"aliases": ["Kimi", "月之暗面"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"qianwen": {
"display_name": "Qianwen",
"domain": "llm",
"provider_code": "qianwen",
"default_url": "https://tongyi.aliyun.com",
"aliases": ["通义千问", "通义", "千问", "Qianwen"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"yiyan": {
"display_name": "YiYan",
"domain": "llm",
"provider_code": "yiyan",
"default_url": "https://yiyan.baidu.com",
"aliases": ["文心一言", "文心", "一言", "YiYan"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"yuanbao": {
"display_name": "Yuanbao",
"domain": "llm",
"provider_code": "yuanbao",
"default_url": "https://yuanbao.tencent.com",
"aliases": ["腾讯元宝", "元宝", "Yuanbao"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"gemini": {
"display_name": "Gemini",
"domain": "llm",
"provider_code": "gemini",
"default_url": "https://gemini.google.com",
"aliases": ["Gemini", "谷歌Gemini", "Google Gemini", "Bard"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"minimax": {
"display_name": "MiniMax",
"domain": "llm",
"provider_code": "minimax",
"default_url": "",
"aliases": ["MiniMax", "minimax"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
# =========================================================================
# Content platforms (carried forward from v1)
# =========================================================================
"sohu": {
"display_name": "搜狐号",
"domain": "content",
"provider_code": "sohu",
"default_url": "https://mp.sohu.com",
"aliases": ["搜狐", "搜狐号"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"toutiao": {
"display_name": "头条号",
"domain": "content",
"provider_code": "toutiao",
"default_url": "https://mp.toutiao.com/",
"aliases": ["头条", "头条号"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"zhihu": {
"display_name": "知乎",
"domain": "content",
"provider_code": "zhihu",
"default_url": "https://www.zhihu.com",
"aliases": ["知乎", "知乎号"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"wechat": {
"display_name": "微信公众号",
"domain": "content",
"provider_code": "wechat",
"default_url": "https://mp.weixin.qq.com",
"aliases": ["公众号", "微信", "微信公众号"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
}
# ---------------------------------------------------------------------------
# Derived alias map
# ---------------------------------------------------------------------------
def _build_alias_map() -> dict[str, str]:
"""Build a case-insensitive flat alias -> key map."""
m: dict[str, str] = {}
for key, spec in PLATFORMS.items():
# Register the key itself (lowercase)
m[key.lower()] = key
# Register all aliases (original + lowercase)
for alias in [spec["display_name"]] + spec.get("aliases", []):
a = str(alias).strip()
if a:
m[a] = key
m[a.lower()] = key
return m
_PLATFORM_ALIAS_MAP = _build_alias_map()
def resolve_platform_key(name: str) -> Optional[str]:
"""
Resolve user input to a canonical platform key.
Supports: key, display_name, any alias. Case-insensitive.
Returns None if unrecognized.
"""
if not name:
return None
s = str(name).strip()
if not s:
return None
sl = s.lower()
# Direct key match
if sl in PLATFORMS:
return sl
# Alias map
if sl in _PLATFORM_ALIAS_MAP:
return _PLATFORM_ALIAS_MAP[sl]
if s in _PLATFORM_ALIAS_MAP:
return _PLATFORM_ALIAS_MAP[s]
return None
def get_platform(key: str) -> Optional[dict[str, Any]]:
"""Return the full platform spec dict for a validated 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.
"""
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)
cur.execute(
"""
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json,
enabled, 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,
updated_at = excluded.updated_at
""",
(
key,
spec["display_name"],
spec["domain"],
spec.get("provider_code"),
spec.get("default_url", ""),
aliases_json,
capabilities_json,
now,
now,
),
)
upsert_count += 1
conn.commit()
log.info("platform_seed_done count=%s", upsert_count)
# ---------------------------------------------------------------------------
# Legacy compatibility helpers (for old account_service v1 code references)
# ---------------------------------------------------------------------------
# Kept for backward compatibility so old imports don't break immediately.
# New code should use PLATFORMS dict directly and resolve_platform_key().
_PLATFORM_PRIMARY_CN: dict[str, str] = {
k: spec["display_name"] for k, spec in PLATFORMS.items()
}
PLATFORM_URLS: dict[str, str] = {
k: (spec.get("default_url") or "") for k, spec in PLATFORMS.items()
}
def _platform_list_cn_for_help() -> str:
"""帮助文案:一行展示名,供 CLI 用法提示。"""
return "".join(
spec["display_name"] for _k, spec in sorted(
PLATFORMS.items(), key=lambda x: x[1]["display_name"]
)
)

View File

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

View File

@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
"""
Data directory, profile path, CLI path debugging output.
"""
import os
import re
import sys
from typing import Optional
from jiangchang_skill_core.runtime_env import get_data_root, get_user_id
from util.constants import SKILL_SLUG
def get_skill_data_dir() -> str:
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
os.makedirs(path, exist_ok=True)
return path
def get_db_path() -> str:
return os.path.join(get_skill_data_dir(), "account-manager.db")
def _runtime_paths_debug_text() -> str:
"""For debugging: print current data path info to stderr."""
env_root = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() or "(未设置)"
env_uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "(未设置→使用 _anon)"
return (
"[account-manager] "
f"JIANGCHANG_DATA_ROOT={env_root} | "
f"JIANGCHANG_USER_ID={env_uid} | "
f"实际数据根={get_data_root()} | "
f"实际用户目录={get_user_id()} | "
f"数据库文件={get_db_path()}"
)
def _maybe_print_paths_debug_cli() -> None:
"""Set JIANGCHANG_ACCOUNT_DEBUG_PATHS=1 to print paths in stderr."""
v = (os.getenv("JIANGCHANG_ACCOUNT_DEBUG_PATHS") or "").strip().lower()
if v in ("1", "true", "yes", "on"):
print(_runtime_paths_debug_text(), file=sys.stderr)
_WIN_FS_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
_WIN_RESERVED_NAMES = frozenset(
["CON", "PRN", "AUX", "NUL"]
+ [f"COM{i}" for i in range(1, 10)]
+ [f"LPT{i}" for i in range(1, 10)]
)
def _fs_safe_segment(name: str, fallback: str) -> str:
"""Single directory segment: strip Windows-unsafe chars; empty -> fallback."""
t = _WIN_FS_FORBIDDEN.sub("_", (name or "").strip())
t = t.rstrip(" .")
if not t:
t = fallback
if t.upper() in _WIN_RESERVED_NAMES:
t = f"_{t}"
return t
def get_default_profile_dir_for_web(
platform_key: str,
label: str,
login_id: Optional[str] = None,
domain: str = "generic",
) -> str:
"""
Generate a browser profile directory path.
Structure:
{DATA_ROOT}/{USER_ID}/account-manager/profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix]/
This keeps profiles organized by domain and platform for easy
identification in file explorer.
"""
label_seg = _fs_safe_segment(label, platform_key)
login_suffix = ""
if login_id:
safe_login = _fs_safe_segment(login_id, "")
if safe_login:
login_suffix = f"_{safe_login[:16]}"
path = os.path.join(
get_skill_data_dir(),
"profiles",
_fs_safe_segment(domain, "generic"),
_fs_safe_segment(platform_key, "unknown"),
f"{label_seg}{login_suffix}",
)
os.makedirs(path, exist_ok=True)
return path

1
tests/__init__.py Normal file
View File

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

49
tests/conftest.py Normal file
View File

@@ -0,0 +1,49 @@
# -*- 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)
import pytest
@pytest.fixture(scope="session")
def fake_env():
"""Set up a fake JIANGCHANG_DATA_ROOT / JIANGCHANG_USER_ID for the session."""
tmp = tempfile.mkdtemp(prefix="account_manager_test_")
data_root = os.path.join(tmp, "claw-data")
os.makedirs(data_root)
fake_uid = "99999"
os.environ["JIANGCHANG_DATA_ROOT"] = data_root
os.environ["JIANGCHANG_USER_ID"] = fake_uid
yield {"data_root": data_root, "user_id": fake_uid, "tmp_dir": tmp}
@pytest.fixture(scope="function")
def clean_db(fake_env):
"""Each test gets a fresh in-memory DB (init_db uses the env vars set by fake_env)."""
# Import after env vars are set
from db.connection import get_conn, init_db
from util.constants import SKILL_SLUG
from util.logging_config import setup_skill_logging
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_test")
init_db()
conn = get_conn()
conn.execute("DELETE FROM account_leases")
conn.execute("DELETE FROM credentials")
conn.execute("DELETE FROM accounts")
conn.commit()
yield conn
conn.close()

21
tests/run_tests.py Normal file
View File

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

View File

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

View File

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

340
tests/test_credentials.py Normal file
View File

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

242
tests/test_lease.py Normal file
View File

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

83
tests/test_platforms.py Normal file
View File

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