From 91057fa3b7d41192d129eb80c73c324690202540 Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Tue, 5 May 2026 18:51:00 +0800 Subject: [PATCH] Release v1.0.56: Credential & Browser Profile Manager --- SKILL.md | 57 +- assets/examples/README.md | 6 +- assets/examples/get-response.json | 26 +- assets/examples/list-json-array.json | 46 +- assets/schemas/account-record.schema.json | 47 +- references/CLI.md | 245 +++-- references/ERRORS.md | 70 +- references/INTEGRATION.md | 88 +- references/PLATFORMS.md | 62 +- references/README.md | 55 +- references/RUNTIME.md | 55 +- references/SCHEMA.md | 122 ++- scripts/cli/app.py | 466 ++++++-- scripts/db/accounts_repo.py | 769 +++++++++++--- scripts/db/connection.py | 123 ++- scripts/db/schema.py | 159 ++- scripts/service/account_service.py | 1181 +++++++++++++++++---- scripts/service/browser_service.py | 55 +- scripts/service/secret_store.py | 306 ++++++ scripts/util/platforms.py | 408 +++++-- scripts/util/runtime_paths.py | 59 +- tests/__init__.py | 1 + tests/conftest.py | 49 + tests/run_tests.py | 21 + tests/test_account_service.py | 297 ++++++ tests/test_browser_open.py | 44 + tests/test_credentials.py | 340 ++++++ tests/test_lease.py | 242 +++++ tests/test_platforms.py | 83 ++ 29 files changed, 4582 insertions(+), 900 deletions(-) create mode 100644 scripts/service/secret_store.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/run_tests.py create mode 100644 tests/test_account_service.py create mode 100644 tests/test_browser_open.py create mode 100644 tests/test_credentials.py create mode 100644 tests/test_lease.py create mode 100644 tests/test_platforms.py diff --git a/SKILL.md b/SKILL.md index 0251386..7fa50b8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,42 +1,45 @@ --- -name: 账号管理 -description: "多平台账号(搜狐号、头条、知乎、微信公众号、豆包、Kimi、DeepSeek 等)的本地登记与查询。当用户或编排要添加/删除账号、列出全部或某平台账号、按 id 查看单条账号信息、为网页自动化批量取账号 JSON 或从中选一条候选、在浏览器中打开已登记账号以登录或核对页面时,加载本技能。本技能不在库中记录「是否已登录」;具体子命令与参数以 references/CLI.md 为准。" -version: 1.0.55 +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) +# 账号与凭据管理(account-manager) -在本地 SQLite 登记各平台账号与持久化用户目录路径,供其它技能通过子进程调用 `scripts/main.py` 读取或维护。**不在库中持久化「是否已登录」**;`pick-web` 仅按规则选一条候选,**不表示**当前浏览器已登录。 +通用 Credential & Browser Profile Manager。在本地 SQLite 管理平台注册、账号身份、凭据元数据(API Key/Token 不存明文)、RPA 并发锁(lease),供其它技能通过 CLI 调用 `scripts/main.py` 获取账号信息。 + +## 核心原则 + +1. **account-manager 是唯一账号/凭据管理入口** — 业务 skill 不能自己存账号、密码、API Key、Token。 +2. **SQLite 只保存元数据** — `secret_ref`、`secret_mask`,不保存原始 secret 明文。 +3. **浏览器登录态** — 使用独立 `profile_dir` 保存 cookie/session,不保存网页登录密码。 +4. **业务 skill 只通过 CLI/API 拿账号** — 不直接读 SQLite。 ## 何时使用本技能 -出现下列**业务场景**时加载;真正执行时严格按 **`references/CLI.md`** 拼 argv,勿臆造参数。子命令与源码函数的对应关系见该文件篇首映射表。 - -1. **登记新账号**:用户给出平台名与大陆 11 位手机号 —— `add`。 -2. **人读查看列表**:要看全部或某一平台的账号台账(多行文本);首参若能识别为平台,可用「只写平台名」或「平台名 + list」的简写 —— `list`(路由见 `cli/app.py`)。 -3. **按 id 取一条机器可读记录**:编排或调试需要单行 JSON —— `get`。 -4. **按平台或全部拉列表机器可读**:stdout 一行 JSON 数组,元素同 `get`,供跨技能编排 —— `list-json`。 -5. **为某平台挑一条网页自动化用号**:stdout 一行 JSON,按更新时间等规则选一条 —— `pick-web`。 -6. **在浏览器里打开已登记账号**:用户说「打开某平台这个账号」「用浏览器打开 id 为 x 的号」等 —— `open`(须已知账号 id;本机 Chrome/Edge + Playwright channel;不写库、不做登录检测)。 -7. **删除账号**:按 id 删一条、按平台清空、或按平台+手机号删一条 —— `delete`(`id` / `platform` / `platform`+手机号 三种形式)。 +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 `(或兼容别名 `open `)— 仅查看,不做登录检测。 +7. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。 ## CLI 调用 -语法、默认值、错误约定、**CLI → service** 映射见 **`references/CLI.md`**。 +语法、默认值、错误约定见 **`references/CLI.md`**。 -**重要约束**:CLI 由宿主或工具执行;**不要把命令原文贴给最终用户看**。添加账号时用户只需在对话中提供**平台名与 11 位完整手机号**;**不要**引导具体宿主菜单路径;**不要**让用户填写 `profile_dir`(`add` 时由实现分配路径)。 +## 支持的平台 -## 平台名称与别名 - -入库为英文 `platform` 键;用户话术可为中文名或别名。见 `references/PLATFORMS.md`;错误前缀见 `references/ERRORS.md`。 +见 `references/PLATFORMS.md`;支持物流、LLM、内容三大业务域。 ## 数据与环境 @@ -44,14 +47,12 @@ allowed-tools: ## 跨技能集成 -`get` / `list-json` / `pick-web` 的 stdout 协议见 `references/INTEGRATION.md`。 +`account get/list/pick-web` / `credential pick` 的 stdout 协议见 `references/INTEGRATION.md`。 -## 依赖与运行时 +## Secret 存储 -- **Python**:共享宿主运行时;勿在技能目录内自建 `.venv`。 -- **浏览器**:`open` 依赖本机 **Chrome 或 Edge** 与 **playwright** 包(channel 模式)。详见 `references/RUNTIME.md`。 -- **环境变量**:`JIANGCHANG_*` / `CLAW_*` 等与兄弟技能一致。 +- `none`:无 secret(浏览器 profile 登录态) +- `env`:SQLite 保存环境变量名,运行时从 `os.environ` 读取 +- `windows_credential`:正式 Windows 部署,真实 secret 存 Windows Credential Manager -## 资产与示例 - -见 `assets/README.md` 及 `assets/examples/`、`assets/schemas/`。 +详见 `references/RUNTIME.md`。 diff --git a/assets/examples/README.md b/assets/examples/README.md index 8e04f72..6d68e88 100644 --- a/assets/examples/README.md +++ b/assets/examples/README.md @@ -1,4 +1,6 @@ # 示例 JSON -- `get-response.json`:`python main.py get ` 成功时 **stdout 单行** 对象的形状参考(示例路径为虚构)。 -- `list-json-array.json`:`list-json` 成功时 **stdout 单行** 数组的形状参考(本文件为多行排版便于阅读)。 +- `get-response.json`:`account get ` 成功时 stdout 单行对象的形状参考(v2 schema,展示为完整 JSON 对象)。 +- `list-json-array.json`:`account list` 或 `list-json` 成功时 stdout 单行数组的形状参考(多行排版便于阅读)。 + +注意:实际输出为 **`{"success":true,"data":...}`** 包裹的格式(get/list 系列),或直接输出数组(list-json 兼容入口)。 diff --git a/assets/examples/get-response.json b/assets/examples/get-response.json index 4f491eb..fc1e109 100644 --- a/assets/examples/get-response.json +++ b/assets/examples/get-response.json @@ -1,10 +1,20 @@ { - "id": 1, - "name": "搜狐1号", - "platform": "sohu", - "phone": "13800138000", - "profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000", - "url": "https://mp.sohu.com", - "created_at": "2026-03-01T10:00:00", - "updated_at": "2026-04-01T12:00:00" + "success": true, + "data": { + "id": 1, + "platform_key": "maersk", + "account_label": "Maersk simulator booking", + "login_id": "demo@maersk.com", + "login_id_type": "email", + "tenant_id": "tenant-demo", + "environment": "simulator", + "role": "booking", + "provider_code": "maersk", + "profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com", + "url": "http://localhost:5180/industries/logistics/maersk/login", + "status": "active", + "session_status": "unknown", + "created_at": "2026-05-01T09:00:00", + "updated_at": "2026-05-01T09:00:00" + } } diff --git a/assets/examples/list-json-array.json b/assets/examples/list-json-array.json index 2046510..e9bc0a5 100644 --- a/assets/examples/list-json-array.json +++ b/assets/examples/list-json-array.json @@ -1,22 +1,36 @@ [ { - "id": 2, - "name": "知乎1号", - "platform": "zhihu", - "phone": "13900001111", - "profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/知乎/13900001111", - "url": "https://www.zhihu.com", - "created_at": "2026-04-02T09:00:00", - "updated_at": "2026-04-02T09:00:00" + "id": 1, + "platform_key": "maersk", + "account_label": "Maersk simulator booking", + "login_id": "demo@maersk.com", + "login_id_type": "email", + "tenant_id": "tenant-demo", + "environment": "simulator", + "role": "booking", + "provider_code": "maersk", + "profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com", + "url": "http://localhost:5180/industries/logistics/maersk/login", + "status": "active", + "session_status": "unknown", + "created_at": "2026-05-01T09:00:00", + "updated_at": "2026-05-01T09:00:00" }, { - "id": 1, - "name": "搜狐1号", - "platform": "sohu", - "phone": "13800138000", - "profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000", - "url": "https://mp.sohu.com", - "created_at": "2026-03-01T10:00:00", - "updated_at": "2026-04-01T12:00:00" + "id": 2, + "platform_key": "deepseek", + "account_label": "DeepSeek production key", + "login_id": "", + "login_id_type": "unknown", + "tenant_id": "", + "environment": "production", + "role": "api", + "provider_code": "deepseek", + "profile_dir": "", + "url": "https://chat.deepseek.com", + "status": "active", + "session_status": "unknown", + "created_at": "2026-05-01T10:00:00", + "updated_at": "2026-05-01T10:00:00" } ] diff --git a/assets/schemas/account-record.schema.json b/assets/schemas/account-record.schema.json index 4f3e9d4..ff38688 100644 --- a/assets/schemas/account-record.schema.json +++ b/assets/schemas/account-record.schema.json @@ -2,18 +2,47 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://openclaw.local/account-manager/account-record.schema.json", "title": "AccountRecord", - "description": "account-manager get / list-json / pick-web 返回的单条账号对象(核心字段;extra_json 合并键不枚举)", + "description": "account-manager account get / pick-web / list 返回的单条账号对象(v2 schema)", "type": "object", - "required": ["id", "name", "platform", "phone", "profile_dir", "url", "created_at", "updated_at"], + "required": ["id", "platform_key", "account_label", "environment", "role", "status", "session_status"], "properties": { - "id": { "type": "integer" }, - "name": { "type": "string" }, - "platform": { "type": "string", "description": "内部英文键,如 sohu、zhihu" }, - "phone": { "type": "string", "description": "归一后的数字串,可能为空字符串" }, + "id": { "type": "integer", "description": "账号主键" }, + "platform_key": { "type": "string", "description": "平台唯一键,如 maersk、deepseek" }, + "account_label": { "type": "string", "description": "人可读名称" }, + "login_id": { "type": "string", "description": "登录标识(email/phone/username/customer_code)" }, + "login_id_type": { "type": "string", "enum": ["email", "username", "phone", "customer_code", "api_account", "unknown"] }, + "tenant_id": { "type": "string", "description": "租户/项目标识" }, + "environment": { "type": "string", "enum": ["simulator", "staging", "production", "test"] }, + "role": { "type": "string", "description": "booking / tracking / admin / sales / api / default" }, + "provider_code": { "type": "string", "description": "供应商代码" }, "profile_dir": { "type": "string", "description": "Playwright 用户数据目录绝对路径" }, - "url": { "type": "string", "format": "uri" }, - "created_at": { "type": ["string", "null"] }, - "updated_at": { "type": ["string", "null"] } + "url": { "type": "string", "description": "账号默认入口 URL" }, + "status": { "type": "string", "enum": ["active", "disabled", "needs_review"] }, + "session_status": { "type": "string", "enum": ["unknown", "likely_valid", "needs_login", "expired"] }, + "last_used_at": { "type": ["string", "null"], "description": "最近使用时间 ISO8601" }, + "last_login_check_at": { "type": ["string", "null"] }, + "last_error_code": { "type": "string" }, + "last_error_message": { "type": "string" }, + "created_at": { "type": ["string", "null"], "description": "创建时间 ISO8601" }, + "updated_at": { "type": ["string", "null"], "description": "更新时间 ISO8601" }, + "credentials": { + "type": "array", + "description": "关联凭据(仅 account get --with-credentials 时返回)", + "items": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "credential_label": { "type": "string" }, + "credential_type": { "type": "string" }, + "secret_storage": { "type": "string" }, + "secret_ref": { "type": "string" }, + "secret_mask": { "type": "string" }, + "status": { "type": "string" } + } + } + }, + "lease_token": { "type": "string", "description": "仅 pick-web --lease 时返回" }, + "lease_expires_at": { "type": "string", "description": "仅 pick-web --lease 时返回" } }, "additionalProperties": true } diff --git a/references/CLI.md b/references/CLI.md index a8f2cdf..721e1ad 100644 --- a/references/CLI.md +++ b/references/CLI.md @@ -4,102 +4,157 @@ ## 子命令与实现(与代码一致) -| CLI(`main.py` 第一参或等价路由) | 实现(`scripts/service/`) | -|----------------------------------|---------------------------| -| `add` | `account_service.cmd_add` | -| `list`;或首参为平台且 `list`/单参 | `account_service.cmd_list`(路由见 `cli/app.py`) | -| `get` | `account_service.cmd_get` | -| `list-json` | `account_service.cmd_list_json` | -| `pick-web` | `account_service.cmd_pick_web` | -| `open` | `browser_service.cmd_open` | -| `delete` | `account_service.cmd_delete_by_id` / `cmd_delete_by_platform` / `cmd_delete_by_platform_phone` | +### 平台管理 -入口:`scripts/main.py` → `cli/app.py` → 上表。 +| CLI | 说明 | +|-----|------| +| `platform list [--domain logistics\|llm\|content]` | 列出平台注册 | +| `platform get ` | 获取平台详情 | + +### 账号管理 + +| CLI | 说明 | +|-----|------| +| `account add-web --platform

--login-id [...]` | 创建网页/RPA 账号 | +| `account add-secret --platform

--credential-type [...]` | 创建 API Key/Token 凭据 | +| `account get [--with-credentials]` | 获取单条账号 JSON | +| `account list [--platform

] [--environment ] [--tenant-id ] [--role ]` | 列出账号 JSON 数组 | +| `account pick-web --platform

[--lease] [--ttl-sec 900]` | 挑选网页自动化候选 | +| `account mark-session --session-status ` | 标记登录状态 | +| `account mark-error --code --message ` | 标记错误 | +| `account mark-used ` | 标记已使用 | +| `account open ` | 打开持久化浏览器 profile(仅查看) | + +### 凭据管理 + +| CLI | 说明 | +|-----|------| +| `credential pick --platform

[--type ] [--reveal]` | 挑选凭据(默认隐藏 secret) | +| `credential resolve --id [--reveal]` | 按 ID 解析凭据 | + +### 租约管理 + +| CLI | 说明 | +|-----|------| +| `lease release ` | 释放租约 | +| `lease list [--active]` | 列出租约 | +| `lease cleanup` | 清理过期租约 | + +### 浏览器 + +| CLI | 说明 | +|-----|------| +| `account open ` | 打开浏览器查看(仅查看) | +| `open ` | 同上(兼容别名) | + +### 兼容入口(旧版别名) + +| CLI | 等价于 | +|-----|--------| +| `get ` | `account get ` | +| `list-json [--limit N]` | JSON 数组输出 | +| `pick-web ` | `account pick-web --platform ` | +| `list [platform\|all]` | 人类可读列表 | +| `add ` | `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`、`搜狐号`)。详见 [PLATFORMS.md](PLATFORMS.md)。 -- **数据与路径**:库文件、profile 目录由环境变量决定,网关与本地终端不一致时会「看不到同一份数据」。详见 [RUNTIME.md](RUNTIME.md)。 -- **机器可读子命令**:`get`、`list-json`、`pick-web` 的成功/失败约定见 [INTEGRATION.md](INTEGRATION.md)。 -- **错误码前缀**:见 [ERRORS.md](ERRORS.md)。 - -> 说明:当前版本 **未实现** `health` / `version` 子命令;与工作区其它技能的统一约定可对齐后另补。 - ---- - -## 子命令一览 - -### `add` — 添加账号 - -```bash -python main.py add <平台> <手机号> -``` - -- 手机号为**中国大陆 11 位**(`1` 开头,第二位 `3–9`),可含空格/分隔符,入库前会归一成数字。 -- 同一 `platform` 下同一手机号不可重复。 - -### `list` — 列出账号(人类可读多行块) - -```bash -python main.py list [平台|all|全部] -``` - -- 省略第二参数时等价于 `all`。 -- 默认最多 **10** 条(按创建时间倒序);内部实现见 `service/account_service.cmd_list`。 - -### 简写:`python main.py <平台>` / `python main.py <平台> list` - -- 若第一个参数能解析为平台,则等价于 `list <平台>`。 - -### `get` — 按 id 输出**单行 JSON** - -```bash -python main.py get -``` - -- 成功:`stdout` **仅一行** JSON 对象(UTF-8)。 -- 失败:首行 `ERROR:ACCOUNT_NOT_FOUND`,路径提示在 stderr。 - -### `list-json` — 列表的**单行 JSON 数组**(跨技能) - -```bash -python main.py list-json <平台|all|全部> [--limit N] -``` - -- 成功:`stdout` **仅一行** JSON 数组;元素结构与 `get` 单条一致。 -- 默认 `limit=200`,实际 clamp 为 `1..500`。 -- 排序:按 `updated_at` 倒序。 - -### `pick-web` — 网页自动化候选账号(单行 JSON) - -```bash -python main.py pick-web <平台> -``` - -- 按该平台 `updated_at`、`created_at` 倒序选一条;**不**表示当前浏览器已登录。 -- 失败:首行 `ERROR:`。 - -### `open` — 仅打开浏览器(**不写库、不检测登录**) - -```bash -python main.py open -``` - -- 需本机 Chrome/Edge + Playwright;详见运行环境说明。 -- 仅供人工核对页面;登录与否由业务侧(如 llm-manager 网页引擎)在使用账号时自行处理。 - -### `delete` — 删除库记录并尽量删除 profile 目录 - -```bash -python main.py delete id -python main.py delete platform <平台> -python main.py delete platform <平台> <手机号> -``` - -- 第二段必须为 `id` 或 `platform`(小写不敏感)。 - ---- - -## 无参数 / 未知子命令 - -- 无参数或无法解析:打印用法概览;未知子命令时首行 `ERROR:CLI_UNKNOWN_OR_BAD_ARGS`,退出码 `1`。 +- **平台参数**:可为 `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)。 diff --git a/references/ERRORS.md b/references/ERRORS.md index 6f8f85f..5bdd7d4 100644 --- a/references/ERRORS.md +++ b/references/ERRORS.md @@ -1,8 +1,20 @@ -# 终端前缀约定(account-manager) +# 终端错误码(account-manager) 约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:` 开头;多行说明可忽略或仅作展示。 -## `ERROR:` — CLI 参数 / 路由 +## 新增机器 JSON 失败格式 + +```json +{ + "success": false, + "error": { + "code": "ERROR:...", + "message": "..." + } +} +``` + +## ERROR:CLI — CLI 参数 / 路由 | 前缀 | 含义 | |------|------| @@ -13,31 +25,55 @@ | `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: — 数据库 | 前缀 | 含义 | |------|------| -| `ERROR:INVALID_PLATFORM_LIST` | `list` 的平台名无法识别 | -| `ERROR:INVALID_PLATFORM_LIST_JSON` | `list-json` 的平台名无法识别 | -| `ERROR:INVALID_PLATFORM` | 其它子命令中平台无法识别 | -| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果(或 `delete platform` 下无记录) | -| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id/记录不存在 | -| `ERROR:PHONE_REQUIRED` | 缺少手机号 | -| `ERROR:PHONE_INVALID` | 手机号格式不符合大陆 11 位规则 | -| `ERROR:DUPLICATE_PHONE_PLATFORM` | 同平台下山手机号已存在 | -| `ERROR:DELETE_INVALID_ID` | 删除 id 非法 | -| `ERROR:NO_ACCOUNT` | `pick-web` 该平台无任何记录 | +| `ERROR:DB_ERROR` | SQLite 写入失败等 | -## `ERROR:` — 浏览器 / 环境 +## ERROR: — 平台与账号 | 前缀 | 含义 | |------|------| -| `ERROR:需要 playwright Python 包:…` | 未安装 `playwright` 包,或本机无 Chrome/Edge(channel 模式,**不**需 `playwright install chromium`) | +| `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)。 +- **`get` / `list-json` / `pick-web`** 成功时:`stdout` 应为**单行 JSON**。 +- 失败时首行 `ERROR:`,可能有后续说明行。 +- **新增机器命令建议统一 JSON 输出**,旧兼容命令可继续输出 `ERROR:` 文本前缀。 diff --git a/references/INTEGRATION.md b/references/INTEGRATION.md index e1f0b4a..e2f71dd 100644 --- a/references/INTEGRATION.md +++ b/references/INTEGRATION.md @@ -1,35 +1,93 @@ # 与其它 Skill 集成(account-manager) +## 核心约束 + +业务 skill **不能自己存账号、密码、API Key、Token**。必须通过 account-manager CLI 获取。 + ## 推荐调用方式 -使用 **`subprocess`** 调用技能根目录下的 `scripts/main.py`,工作目录可为技能根或 `scripts/`(只要命令中的 `python` 与路径一致)。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`** 必须与最终用户会话一致,否则读写到错误库。 +使用 **`subprocess`** 调用 `scripts/main.py`,工作目录可为技能根或 `scripts/`。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`** 必须与最终用户会话一致。 ## 机器可读命令摘要 | 目的 | 命令 | 成功时 stdout | |------|------|----------------| -| 取单条账号 JSON | `get ` | 单行 JSON 对象 | -| 批量账号 JSON | `list-json <平台或all> [--limit N]` | 单行 JSON 数组 | -| 网页自动化候选账号 | `pick-web <平台>` | 单行 JSON 对象(按 `updated_at` 选一条,**不**表示当前已登录) | +| 取单条账号 JSON | `account get ` | 单行 JSON 对象 | +| 批量账号 JSON | `account list --platform

[--environment ]` | 单行 JSON 数组 | +| 网页自动化候选 | `account pick-web --platform

[--lease]` | 单行 JSON 对象 | +| 获取 API Key | `credential pick --platform

--type api_key [--reveal]` | 单行 JSON(`success: true` + 字段) | +| 兼容入口 | `get ` / `list-json

` / `pick-web

` | 同上 | -## 解析协议(强建议) +## 解析协议 -1. 读取子进程 **stdout** 全文或首行。 -2. 若首行以 **`ERROR:`** 开头 → 失败,不要 `json.loads` 整段 stdout。 -3. **`get` / `list-json` / `pick-web`**:成功时应对**第一行**做 `json.loads`;后续行应不存在,若有则视为非协议内容。 +1. 读取子进程 **stdout** 首行。 +2. 若首行以 **`ERROR:`** 开头 → 失败。 +3. 否则对首行做 `json.loads`。 -## JSON 对象字段(与 `get` 一致) +## JSON 对象字段 -典型字段见仓库 `assets/examples/get-response.json` 与 `assets/schemas/account-record.schema.json`。 +### account get / pick-web -常见键名包括:`id`, `name`, `platform`, `phone`, `profile_dir`, `url`, `created_at`, `updated_at`;`extra_json` 列中的扁平扩展可能并入同一对象。 +```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` / `get` 返回的 `profile_dir` 同一浏览器会话)。 +pick-web 额外字段(带 lease 时): +- `lease_token`: 租约 token +- `lease_expires_at`: 过期时间 -## Publisher / 自动化流水线示例 +### credential pick -1. `pick-web 搜狐号`(或目标平台)→ 解析 JSON → 取 `id`、`profile_dir`、`url`。 -2. 使用 `profile_dir` 启动持久化浏览器上下文,在业务页面内完成或等待登录。 +```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 ` +5. 如遇登录失效:`account mark-session --session-status needs_login` + +### LLM API 调用 + +1. `credential pick --platform deepseek --type api_key --environment production --reveal` +2. 解析 JSON → 取 `secret` 字段 +3. 在业务进程内存中使用,不写文件、不打日志 ## 退出码 diff --git a/references/PLATFORMS.md b/references/PLATFORMS.md index 0826ca6..7277894 100644 --- a/references/PLATFORMS.md +++ b/references/PLATFORMS.md @@ -1,24 +1,50 @@ # 平台与别名(account-manager) -**权威来源**:`scripts/util/platforms.py` 中的 `PLATFORMS`。下表便于 AI/文档阅读;若与代码不一致,**以代码为准**。 +**权威来源**:`scripts/util/platforms.py` 中的 `PLATFORMS`。下表便于文档阅读;若与代码不一致,**以代码为准**。 -入库字段 `accounts.platform` 使用下表 **英文键**(小写)。CLI 与用户话术可使用 **展示名** 或 **别名**。 +## 物流平台 -| 英文键 (`platform`) | 展示名 (`label`) | 默认账号名前缀 (`prefix`) | 别名 (`aliases`) | 默认 URL | -|---------------------|------------------|---------------------------|------------------|----------| -| `sohu` | 搜狐号 | 搜狐 | 搜狐 | https://mp.sohu.com | -| `toutiao` | 头条号 | 头条 | 头条 | https://mp.toutiao.com/ | -| `zhihu` | 知乎 | (同 label) | 知乎号 | https://www.zhihu.com | -| `wechat` | 微信公众号 | 微信 | 公众号、微信 | https://mp.weixin.qq.com | -| `kimi` | Kimi | (同 label) | 月之暗面 | https://kimi.moonshot.cn | -| `deepseek` | DeepSeek | (同 label) | — | https://chat.deepseek.com | -| `doubao` | 豆包 | (同 label) | — | https://www.doubao.com | -| `qianwen` | 通义千问 | 通义 | 通义、千问 | https://tongyi.aliyun.com | -| `yiyan` | 文心一言 | 文心 | 文心、一言 | https://yiyan.baidu.com | -| `yuanbao` | 腾讯元宝 | 元宝 | 元宝 | https://yuanbao.tencent.com | -| `gemini` | Gemini | Gemini | 谷歌Gemini、Google Gemini、google gemini、Bard | https://gemini.google.com | +| 英文键 | 展示名 | 供应商代码 | 别名 | +|--------|--------|-----------|------| +| `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 平台 -- 英文键本身、各 `label`、各 `aliases` 条目(及小写形式)均可解析到对应键。 -- 无法识别时,相关子命令会输出 `ERROR:INVALID_PLATFORM` 或 `ERROR:INVALID_PLATFORM_LIST` / `INVALID_PLATFORM_LIST_JSON` 等,并附带「支持的平台」提示。 +| 英文键 | 展示名 | 别名 | 默认 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` 表。 diff --git a/references/README.md b/references/README.md index d0678ac..5410d7c 100644 --- a/references/README.md +++ b/references/README.md @@ -1,51 +1,52 @@ --- -description: "统一管理搜狐、头条、知乎、微信、豆包、Kimi、DeepSeek 等多平台账号与浏览器配置目录。添加账号后,其它技能(如大模型写作、平台发布)可按平台自动选用对应环境。" +description: "通用 Credential & Browser Profile Manager:管理网页账号、RPA 浏览器 profile、LLM/API 平台 API Key。支持物流、LLM、内容三大业务域。" --- -# 👤 账号管理 +# 🔐 账号与凭据管理 -把你在各平台的账号**登记到本地**,后续「用大模型写稿」「发布到搜狐号」等技能才能按平台选用对应账号。 +把你在各平台的账号与凭据**登记到本地**,后续物流 RPA、大模型写作、内容发布等技能才能自动选用对应环境。 -> 本技能**只负责登记账号**,不在此处判断「有没有登录网站」。真正打开网页时,由对应业务(例如大模型网页版)在同窗口里提示你完成登录。 +> 本技能是**唯一**账号/凭据管理入口。业务 skill 不自己存密码、API Key,而是通过 CLI 调用获取。 ## 它能帮你做什么 -- **添加账号** —— 选一个平台(如豆包、搜狐号),在对话里说出 **11 位大陆手机号**即可。 -- **查看列表** —— 按平台或全部列出已登记的账号。 -- **删除账号** —— 按 id、按平台整批、或按「平台 + 手机号」删除,并可清理对应本地目录。 -- **给其它技能用** —— 自动化的技能会通过内部接口按平台**取一条候选账号**(不要求你手工抄 id)。 +- **添加网页/RPA 账号** — 选平台,给出登录标识(邮箱/手机/用户名),自动创建浏览器 profile 目录。 +- **添加 API Key/Token** — 安全存储:API Key 明文存入 Windows Credential Manager 或环境变量引用,SQLite 只保存元数据和打码值。 +- **查看/列出账号** — 按平台、环境、角色等多维度筛选。 +- **给 RPA 技能用** — `pick-web` 按规则挑候选账号,支持 lease 防并发。 +- **给 LLM 技能用** — `credential pick` 安全获取 API Key。 +- **在浏览器里查看** — `open ` 打开 Chrome/Edge 查看,仅查看不写库。 ## 你可以这样对它说 -- 「帮我添加一个豆包账号,手机号 138xxxx。」 -- 「列出我所有的搜狐号账号。」 -- 「删除头条号里手机尾号 3701 的那条。」 -- 「我想看看 3 号账号在浏览器里打开是什么页面。」 +- 「帮我添加一个 Maersk 模拟器账号,邮箱 demo@maersk.com。」 +- 「添加 DeepSeek 的 API Key,存在 Windows 凭据管理器里。」 +- 「列出所有物流平台的账号。」 +- 「用浏览器打开 3 号账号看看页面。」 +- 「给 RPA 技能取一个 maersk 模拟器的 booking 账号。」 ## 你需要提供什么 | 场景 | 你需要给的 | |---|---| -| 添加账号 | **平台名称**(可说中文,如豆包、搜狐号) + **大陆 11 位手机号**(同平台下同号不能重复) | -| 删除 | 账号 **id**,或平台名,或平台名 + 手机号 | -| 仅打开浏览器核对 | 账号 **id**(需本机已安装 Chrome 或 Edge) | - -## 失败了怎么办 - -- **提示手机号格式不对**:需 **1 开头、第二位 3~9、共 11 位数字**。 -- **提示同平台手机号已存在**:该平台上这条号已经加过,无需重复添加。 -- **列表是空的但你觉得应该有**:可能是当前设备与云端使用了**不同的数据目录**;在宿主设置里确认数据根目录与账号一致,或联系管理员。 +| 添加网页账号 | **平台名称** + **登录标识**(邮箱/手机/用户名) | +| 添加 API Key | **平台名称** + **凭据类型**(api_key 等)+ **secret**(通过 stdin 或环境变量) | +| 查看/列出 | 可选:平台名、环境、角色等筛选条件 | +| 浏览器打开 | 账号 **id**(需本机已安装 Chrome 或 Edge) | ## 它不做什么 -- ❌ 不在此处替你**判定**各网站「是否已登录」(由使用账号的具体功能负责) -- ❌ 不代替**内容管理、发布、大模型调用**等业务技能(本技能只提供账号与目录信息) -- ❌ 不存储你的**大模型 API Key**(那是大模型管理技能的职责) +- 不在此处判定各网站「是否已登录」(由业务技能自行处理) +- 不代替内容管理、发布、大模型调用等业务技能 +- 不保存 API Key 明文在 SQLite 中(仅存引用和打码值) -## 相关技能 +## Secret 存储方式 -- **大模型管理**:写稿时若走网页版大模型,会依赖本技能登记的账号与目录。 -- **内容管理、各平台发布**:按平台取账号时也会用到本技能的数据。 +| 方式 | 说明 | +|---|---| +| `none` | 无 secret(浏览器 profile 登录态) | +| `env` | 引用环境变量名,运行时读取 | +| `windows_credential` | 正式推荐:真实 secret 存 Windows Credential Manager | --- diff --git a/references/RUNTIME.md b/references/RUNTIME.md index de4b65c..cc0e34d 100644 --- a/references/RUNTIME.md +++ b/references/RUNTIME.md @@ -1,48 +1,59 @@ # 运行环境与数据路径(account-manager) -## 数据落盘位置(当前实现) +## 数据落盘位置 -`get_data_root()` / `get_user_id()` 来自 **`scripts/jiangchang_skill_core/runtime_env.py`**(与 `jiangchang-platform-kit` 同源);`scripts/util/runtime_paths.py` 在此基础上拼技能子目录。 +`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**:优先 **`JIANGCHANG_USER_ID`**;未设置时为 **`_anon`**。 - **技能数据目录**:`{数据根}/{用户id}/account-manager/` - **SQLite**:`account-manager.db` - - **Profile**:`profiles/<平台展示名>/<手机号>/`(由 `get_default_profile_dir` 生成) - - **日志**:与其它技能共用**用户级**目录 `{数据根}/{用户id}/logs/jiangchang.log`(按日轮转) + - **Profile**:`profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix}]/` + - **日志**:与其它技能共用 `{数据根}/{用户id}/logs/jiangchang.log` ## 调试:为什么「list 没数据」 -- 若终端与网关注入的 `JIANGCHANG_*` 不一致,会写到**不同用户目录**,库文件不是同一份。 -- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`**(`1`/`true`/`yes`/`on`)后,每次 CLI 子命令前会在 **stderr** 打印实际根目录、用户 id 与数据库路径(`_maybe_print_paths_debug_cli`)。 +- 若终端与网关注入的 `JIANGCHANG_*` 不一致,会写到**不同用户目录**。 +- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`** 后,每次 CLI 命令前在 **stderr** 打印实际路径。 -## 本地 CLI 默认值(`runtime_env`) +## 本地 CLI 默认值 -各技能 **`main.py`** 在 `sys.path` 就绪后、import 业务包之前调用 **`apply_cli_local_defaults()`**(实现见 `jiangchang_skill_core.runtime_env`): +各技能 `main.py` 调用 `apply_cli_local_defaults()`: +- `CLI_LOCAL_DEV_ENABLED = True` 时,未设置环境变量则注入平台默认值。 +- 发版前改为 `False` 或依赖宿主注入。 -- 代码内 **`CLI_LOCAL_DEV_ENABLED = True`** 时,若当前未设置 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`,会注入平台默认数据根与 **`DEFAULT_LOCAL_USER_ID`**(与 kit 中常量一致)。 -- 代码内为 **`False`** 时,可设环境变量 **`JIANGCHANG_CLI_LOCAL_DEV=1`** 达到同样效果。 -- **发版/嵌入宿主前**:将各技能 vendored 的 `runtime_env.py` 中 `CLI_LOCAL_DEV_ENABLED` 改为 **`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 ## 日志 -实现见 `scripts/jiangchang_skill_core/unified_logging.py`,`util/logging_config.py` 仅 re-export。 - | 变量 | 作用 | |------|------| | `JIANGCHANG_LOG_LEVEL` | 日志级别,默认 `INFO` | | `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 等时,WARNING 及以上同步 stderr | -| `JIANGCHANG_LOG_FILE` | 覆盖主日志文件绝对路径(默认 `{数据根}/{用户id}/logs/jiangchang.log`) | +| `JIANGCHANG_LOG_FILE` | 覆盖主日志文件绝对路径 | | `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` | -| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入以串联同一调用链 | +| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入 | ## 浏览器(open) -`open` 由 `service/browser_service.py` 与子进程 `service/open_child_runner.py` 启动持久化上下文,仅用于人工查看;**不做**登录态 DOM 检测。 +- 本机已安装 Chrome 或 Edge +- Python 环境需 `playwright` 包(匠厂共享 runtime 已包含) +- 使用 **channel 模式**(`channel=chrome|msedge`),**不下载 Chromium** +- **不要执行 `playwright install chromium`** -依赖:**本机已安装 Chrome 或 Edge**;Python 环境需 **`playwright` 包**(匠厂共享 `python-runtime` 已包含)。运行时使用 **Playwright `channel=chrome|msedge`** 调系统浏览器,**不要**执行 `playwright install chromium`(避免下载自带 Chromium)。 +### 关键浏览器参数 -## 常见问题(简要) - -- **`ERROR:需要 playwright Python 包`**:确认共享 venv 已 `uv sync`;本机安装 Chrome 或 Edge。 -- **`ERROR:PROFILE_DIR_MISSING`**:库中该账号 `profile_dir` 为空,需检查数据或重新添加账号流程。 +- `headless=False` +- `no_viewport=True` +- `args` 包含 `--start-maximized` +- stealth 模式可选(`OPENCLAW_PLAYWRIGHT_STEALTH` 环境变量控制) diff --git a/references/SCHEMA.md b/references/SCHEMA.md index 55fd726..ecf9426 100644 --- a/references/SCHEMA.md +++ b/references/SCHEMA.md @@ -1,28 +1,122 @@ -# 数据存储(account-manager) +# 数据库表结构(account-manager v2) ## 数据库路径 `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/account-manager/account-manager.db` -`JIANGCHANG_DATA_ROOT` 与 `JIANGCHANG_USER_ID` 由宿主注入,与兄弟技能保持一致。路径解析细节见 [RUNTIME.md](RUNTIME.md)。 +路径解析细节见 [RUNTIME.md](RUNTIME.md)。 -## `accounts` 表(核心字段) +## Schema 版本 -- `id` —— 自增主键,CLI 与跨技能常称「账号 id」 -- `name` —— 展示用名称(如「豆包1号」) -- `platform` —— 平台英文键(小写,如 `doubao`、`sohu`),见 [PLATFORMS.md](PLATFORMS.md) -- `phone` —— 归一化后的 11 位数字串 -- `profile_dir` —— 持久化浏览器用户数据目录绝对路径,供 Playwright 等复用 -- `url` —— 该平台默认入口 URL -- `extra_json` —— 可选扩展(JSON 文本),`get` 成功时可能并入返回对象 -- `created_at` / `updated_at` —— Unix 秒;`pick-web` 等按更新时间排序选候选 +`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 文件布局 -浏览器配置目录通常位于技能数据目录下的 `profiles/` 分层路径,由实现生成。删除账号时实现会尽量同时删除对应目录,见 [CLI.md](CLI.md) `delete` 说明。 +``` +{DATA_ROOT}/{USER_ID}/account-manager/ +├── account-manager.db +└── profiles/ + └── {domain}/ + └── {platform_key}/ + └── {safe_label}[_{login_id_suffix}]/ +``` + +删除账号时实现会尽量同时删除对应目录。 ## 与 JSON 输出的对应关系 -`get` / `list-json` / `pick-web` 单行 JSON 的键名与上表及 `extra_json` 展开规则见 [INTEGRATION.md](INTEGRATION.md) 与仓库 `assets/schemas/`。 +`account get/list/pick-web` 和 `credential pick` 的 JSON 输出字段与上表一一对应。详见 [INTEGRATION.md](INTEGRATION.md) 与 `assets/schemas/`。 diff --git a/scripts/cli/app.py b/scripts/cli/app.py index e9e3a43..e0069b4 100644 --- a/scripts/cli/app.py +++ b/scripts/cli/app.py @@ -1,15 +1,41 @@ -"""CLI 入口:用法说明与 argv 分发。""" +# -*- 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_add, + 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 @@ -17,97 +43,223 @@ from util.logging_config import get_skill_logger, setup_skill_logging from util.platforms import _platform_list_cn_for_help, resolve_platform_key from util.runtime_paths import _maybe_print_paths_debug_cli +# ========================================================================= +# Help text +# ========================================================================= -def _cli_print_full_usage() -> None: - """总览(未带子命令或需要完整说明时)。""" - print("用法概览(将 main.py 换为你的路径):") - print(" python main.py add <平台中文名> <手机号>") - print(" python main.py list [平台|all|全部]") - print(" python main.py pick-web <平台> # 跨技能用:按最近更新选一条账号,一行 JSON") - print(" python main.py list-json <平台|all> [--limit N] # 跨技能用:一行 JSON 数组,元素同 get") - print(" python main.py get ") - print(" python main.py open ") - print(" python main.py delete id ") - print(" python main.py delete platform <平台>") - print(" python main.py delete platform <平台> <手机号>") - print(" python main.py <平台> # 等价于只列该平台") - print(" python main.py <平台> list # 同上") - print() - print("支持的平台(可用下列中文称呼,亦支持英文键如 sohu):") - print(" " + _platform_list_cn_for_help()) +_USAGE = """account-manager v2 — Credential & Browser Profile Manager + +平台管理: + platform list [--domain logistics|llm|content] + platform get + +账号管理: + account add-web --platform

--login-id --login-id-type + [--label

--credential-type + --secret-storage env|windows_credential|none + [--secret-ref ] [--secret-stdin] + [--label

] [--environment ] + [--tenant-id ] [--role ] [--limit N] + account pick-web --platform

[--environment ] [--tenant-id ] + [--role ] [--lease] [--ttl-sec 900] + [--holder ] [--purpose ] + account mark-session --session-status + account mark-error --code --message + account mark-used + account open + +凭据管理: + credential pick --platform

[--type ] [--environment ] + [--role ] [--reveal] + credential resolve --id [--reveal] + +租约管理: + lease release + lease list [--active] + lease cleanup + +浏览器: + account open + open # 兼容别名 + +兼容入口(旧版): + get + list-json [--limit N] + pick-web + list [platform|all] + add + delete id|platform <...> +""" -def _cli_fail_add() -> None: - print("ERROR:CLI_ADD_MISSING_ARGS") - print() - print("【add 添加账号】参数不足。") - print(" 必填:平台名称(中文即可,如 搜狐号、知乎、微信公众号)") - print(" 必填:中国大陆 11 位手机号(1 开头,第二位 3~9),同平台下不可重复") - print() - print("示例:") - print(" python main.py add 搜狐号 13800138000") - print(" python main.py add 知乎 13900001111") - print() - print("支持的平台:" + _platform_list_cn_for_help()) +# ========================================================================= +# Argument parsing helpers +# ========================================================================= + +def _get_opt(argv: list[str], flag: str, default=None): + """Get value of --flag from argv, returning default if not found.""" + if flag in argv: + idx = argv.index(flag) + if idx + 1 < len(argv): + return argv[idx + 1] + return default -def _cli_fail_need_account_id(subcmd: str) -> None: - print(f"ERROR:CLI_{subcmd.upper()}_MISSING_ID") - print() - print(f"【{subcmd}】缺少必填参数:账号 id(数据库自增整数,可先 list 查看)。") - print("示例:") - print(f" python main.py {subcmd} 1") +def _has_flag(argv: list[str], flag: str) -> bool: + return flag in argv -def _cli_fail_delete() -> None: - print("ERROR:CLI_DELETE_MISSING_ARGS") - print() - print("【delete 删除账号】参数不足。支持三种方式:") - print(" 按 id:python main.py delete id ") - print(" 按平台(删该平台下全部):python main.py delete platform <平台中文名或英文键>") - print(" 按平台+手机号:python main.py delete platform <平台> <手机号>") - print() - print("示例:") - print(" python main.py delete id 3") - print(" python main.py delete platform 搜狐号") - print(" python main.py delete platform 头条号 18925203701") - print() - print("说明:会同时删除数据库记录与 profile 用户数据目录(若存在)。") +def _get_int_opt(argv: list[str], flag: str, default: int) -> int: + v = _get_opt(argv, flag) + if v is not None: + try: + return int(v) + except ValueError: + return default + return default +# ========================================================================= +# Main dispatch +# ========================================================================= + def main() -> None: + setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME) + log = get_skill_logger() + if len(sys.argv) < 2: - _cli_print_full_usage() + print(_USAGE) sys.exit(1) - setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME) - get_skill_logger().info("cli_start argv=%s", sys.argv) - cmd = sys.argv[1] + log.info("cli_start argv=%s", sys.argv) + av = sys.argv + cmd = av[1] _maybe_print_paths_debug_cli() + # Determine if first arg is a platform key (for legacy shorthand) plat_from_first = resolve_platform_key(cmd) - if len(sys.argv) >= 3 and sys.argv[2] == "list" and plat_from_first: - cmd_list(plat_from_first) - elif plat_from_first and len(sys.argv) == 2: - cmd_list(plat_from_first) - elif cmd == "add": - if len(sys.argv) < 4: - _cli_fail_add() + # ---- platform subcommand ---- + if cmd == "platform": + if len(av) < 3: + print("ERROR:CLI_BAD_ARGS platform 需要子命令:list | get") sys.exit(1) - cmd_add(sys.argv[2], sys.argv[3]) - elif cmd == "list": - cmd_list(sys.argv[2] if len(sys.argv) >= 3 else "all") + sub = av[2] + if sub == "list": + domain = _get_opt(av, "--domain") + cmd_platform_list(domain) + elif sub == "get": + if len(av) < 4: + print("ERROR:CLI_BAD_ARGS platform get 需要 。") + 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 需要 。") + 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 需要 。") + 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 需要 。") + 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 需要 。") + 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 需要 。") + 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 需要 。") + sys.exit(1) + cmd_lease_release(av[3]) + elif sub == "list": + active = _has_flag(av, "--active") + cmd_lease_list(active) + elif sub == "cleanup": + cmd_lease_cleanup() + else: + print("ERROR:CLI_BAD_ARGS lease 子命令必须是 release、list 或 cleanup。") + sys.exit(1) + + # ---- Legacy aliases ---- elif cmd == "get": if len(sys.argv) < 3: - _cli_fail_need_account_id("get") + print("ERROR:CLI_GET_MISSING_ID") sys.exit(1) cmd_get(sys.argv[2]) + elif cmd == "list-json": if len(sys.argv) < 3: print("ERROR:CLI_LIST_JSON_MISSING_PLATFORM") - print("用法:python main.py list-json <平台|all|全部> [--limit N]") - print("说明:跨技能编排;成功时 stdout 仅一行 JSON 数组(元素与 get 一致)。") sys.exit(1) av = sys.argv[2:] platform_arg = av[0] @@ -120,21 +272,45 @@ def main() -> None: 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") - print("用法:python main.py pick-web <平台中文名或英文键>") - print("说明:按 updated_at 选一条账号;是否已登录由调用方网页会话自行处理。") sys.exit(1) cmd_pick_web(sys.argv[2]) + elif cmd == "open": if len(sys.argv) < 3: - _cli_fail_need_account_id("open") + print("ERROR:CLI_OPEN_MISSING_ID") sys.exit(1) cmd_open(sys.argv[2]) + + elif cmd == "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: - _cli_fail_delete() + print("ERROR:CLI_DELETE_MISSING_ARGS") sys.exit(1) mode = (sys.argv[2] or "").strip().lower() if mode == "id": @@ -145,17 +321,151 @@ def main() -> None: else: cmd_delete_by_platform(sys.argv[3]) else: - print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]}」,请使用 id 或 platform。") - _cli_fail_delete() + print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]}」。") sys.exit(1) + + # ---- Platform shorthand: "python main.py " ---- + elif plat_from_first: + cmd_list(sys.argv[1]) + + # ---- Unknown ---- else: - print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别,或参数组合不合法。") + print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别。") print() - _cli_print_full_usage() - print() - print("说明:list 的筛选可为 all/全部,或上面列出的任一平台中文名。") + print(_USAGE) sys.exit(1) +# ========================================================================= +# Sub-parsers for complex commands +# ========================================================================= + +def _cmd_add_web(argv: list[str]) -> None: + plat = _get_opt(argv, "--platform", "") + login_id = _get_opt(argv, "--login-id") + login_id_type = _get_opt(argv, "--login-id-type", "unknown") + label = _get_opt(argv, "--label") + tenant_id = _get_opt(argv, "--tenant-id") + environment = _get_opt(argv, "--environment", "production") + role = _get_opt(argv, "--role", "default") + provider_code = _get_opt(argv, "--provider-code") + url = _get_opt(argv, "--url") + extra_json_str = _get_opt(argv, "--extra-json") + profile_dir_override = _get_opt(argv, "--profile-dir") + + if not plat: + print("ERROR:CLI_BAD_ARGS account add-web 需要 --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 。") + 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 。") + 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 。") + 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 。") + return + cmd_credential_resolve(cid, reveal=reveal) + + if __name__ == "__main__": main() diff --git a/scripts/db/accounts_repo.py b/scripts/db/accounts_repo.py index 06b1c9c..9566e78 100644 --- a/scripts/db/accounts_repo.py +++ b/scripts/db/accounts_repo.py @@ -1,19 +1,29 @@ -"""Accounts table: CRUD and queries (no stdout).""" +# -*- coding: utf-8 -*- +""" +Accounts, Credentials, Leases, and Platforms CRUD (no stdout). + +All functions operate on a connection (passed or opened locally). +Time fields are INTEGER Unix seconds (UTC). +""" import json import time from datetime import datetime from typing import Any, Optional from db.connection import get_conn, init_db -from util.platforms import PLATFORM_URLS, _PLATFORM_NAME_CN +from util.logging_config import get_skill_logger +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + def _now_unix() -> int: return int(time.time()) def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]: - """对外 JSON:本地时区 ISO8601 字符串,便于人读。""" + """Convert Unix timestamp to local ISO8601 string, or None.""" if ts is None: return None try: @@ -22,197 +32,636 @@ def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]: return None -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 _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]), + } -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}" +# ============================================================================ +# Platforms +# ============================================================================ - -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, extra_json, - created_at, updated_at - FROM accounts WHERE id = ? - """, - (aid,), - ) - row = cur.fetchone() - if not row: - return None - acc: dict[str, Any] = { - "id": row[0], - "name": row[1], - "platform": row[2], - "phone": row[3] or "", - "profile_dir": (row[4] or "").strip(), - "url": row[5] or PLATFORM_URLS.get(row[2], ""), - "created_at": _unix_to_iso_local(row[7]), - "updated_at": _unix_to_iso_local(row[8]), - } - if row[6]: - try: - extra = json.loads(row[6]) - if isinstance(extra, dict): - acc.update(extra) - except Exception: - pass - return acc - finally: - conn.close() - - -def fetch_list_rows(platform_key: str, limit: int): - """platform_key 'all' or platform slug; returns list of row tuples (full row).""" - conn = get_conn() - try: - cur = conn.cursor() - sql = ( - "SELECT id, name, platform, phone, profile_dir, url, " - "extra_json, created_at, updated_at FROM accounts " - ) - if platform_key == "all": - cur.execute(sql + "ORDER BY created_at DESC, id DESC LIMIT ?", (int(limit),)) - else: - cur.execute( - sql + "WHERE platform = ? ORDER BY created_at DESC, id DESC LIMIT ?", - (platform_key, int(limit)), - ) - return cur.fetchall() - finally: - conn.close() - - -def fetch_ids_for_list_json(platform_key: str, lim: int): - conn = get_conn() - try: - cur = conn.cursor() - if platform_key == "all": - cur.execute( - "SELECT id FROM accounts ORDER BY updated_at DESC, id DESC LIMIT ?", - (lim,), - ) - else: - cur.execute( - "SELECT id FROM accounts WHERE platform = ? " - "ORDER BY updated_at DESC, id DESC LIMIT ?", - (platform_key, lim), - ) - return [r[0] for r in cur.fetchall()] - finally: - conn.close() - - -def fetch_pick_web_candidate_id(platform_key: str) -> Optional[int]: - """该平台一条账号:按 updated_at、created_at 倒序,与是否登录无关。""" - conn = get_conn() - try: - cur = conn.cursor() - cur.execute( - """ - SELECT id FROM accounts - WHERE platform = ? - ORDER BY updated_at DESC, created_at DESC, id DESC - LIMIT 1 - """, - (platform_key,), - ) - row = cur.fetchone() - return int(row[0]) if row else None - finally: - conn.close() - - -def has_duplicate_phone_conn(conn, platform_key: str, phone_store: str) -> bool: +def platform_exists(conn, platform_key: str) -> bool: cur = conn.cursor() - cur.execute( - "SELECT id FROM accounts WHERE platform = ? AND phone = ?", - (platform_key, phone_store), - ) + cur.execute("SELECT 1 FROM platforms WHERE platform_key = ?", (platform_key,)) return cur.fetchone() is not None -def count_platform_accounts_conn(conn, platform_key: str) -> int: +def list_platforms_from_db(conn, domain: Optional[str] = None) -> list[dict]: cur = conn.cursor() - cur.execute("SELECT COUNT(*) FROM accounts WHERE platform = ?", (platform_key,)) - return int(cur.fetchone()[0]) + 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 insert_account_row( +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, - name: str, platform_key: str, - phone_store: str, - profile_dir: str, - url: 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 (name, platform, phone, profile_dir, url, extra_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, NULL, ?, ?) + 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', ?, ?, ?) """, - (name, platform_key, phone_store, profile_dir, url, now, now), + ( + 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 fetch_delete_row_by_id_conn(conn, aid: int): +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, name, platform, phone, profile_dir FROM accounts WHERE id = ?", - (aid,), + "SELECT id FROM accounts WHERE platform_key = ? AND login_id = ? AND environment = ? AND role = ?", + (platform_key, login_id, environment, role), ) - return cur.fetchone() + return cur.fetchone() is not None -def delete_account_by_id_conn(conn, rid: int) -> None: - cur = conn.cursor() - cur.execute("DELETE FROM accounts WHERE id = ?", (rid,)) +# ============================================================================ +# Credentials +# ============================================================================ - -def fetch_ids_profile_for_platform_conn(conn, platform_key: str): +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( - "SELECT id, profile_dir FROM accounts WHERE platform = ?", - (platform_key,), + """ + 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 cur.fetchall() + return int(cur.lastrowid) -def delete_accounts_by_platform_conn(conn, platform_key: str) -> None: - cur = conn.cursor() - cur.execute("DELETE FROM accounts WHERE platform = ?", (platform_key,)) +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 fetch_row_platform_phone_conn(conn, platform_key: str, phone_norm: str): - cur = conn.cursor() - cur.execute( - "SELECT id, name, profile_dir FROM accounts WHERE platform = ? AND phone = ?", - (platform_key, phone_norm), - ) - return cur.fetchone() +def 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 delete_account_platform_phone_conn(conn, platform_key: str, phone_norm: str) -> None: - cur = conn.cursor() - cur.execute( - "DELETE FROM accounts WHERE platform = ? AND phone = ?", - (platform_key, phone_norm), - ) +def 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 diff --git a/scripts/db/connection.py b/scripts/db/connection.py index 7c2029a..326c905 100644 --- a/scripts/db/connection.py +++ b/scripts/db/connection.py @@ -1,21 +1,124 @@ -"""SQLite connection and schema bootstrap.""" -import sqlite3 +# -*- coding: utf-8 -*- +""" +SQLite connection with schema migration (v1 -> v2). -from db.schema import ACCOUNTS_TABLE_SQL +Migration strategy: +- Check PRAGMA user_version. +- If version < 2 and old 'accounts' table exists, back it up as + 'accounts_legacy_backup_' 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(): - return sqlite3.connect(get_db_path()) +def get_conn() -> sqlite3.Connection: + """Open a connection to the skill database (autocommit off).""" + conn = sqlite3.connect(get_db_path()) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=OFF") + return conn -def init_db(): +def _backup_old_accounts(conn: sqlite3.Connection) -> bool: + """Rename old 'accounts' table to 'accounts_legacy_backup_' 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("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'") - if not cur.fetchone(): - cur.executescript(ACCOUNTS_TABLE_SQL) - conn.commit() + cur.execute("PRAGMA user_version") + row = cur.fetchone() + return int(row[0]) if row else 0 finally: conn.close() diff --git a/scripts/db/schema.py b/scripts/db/schema.py index 69e1c88..61405c5 100644 --- a/scripts/db/schema.py +++ b/scripts/db/schema.py @@ -1,16 +1,145 @@ -# SQLite 无独立 DATETIME 类型: 时间统一存 INTEGER Unix 秒 (UTC), 查询/JSON 再转 ISO8601. -# 下列 DDL 含注释, 会原样写入 sqlite_master, 便于 Navicat / DBeaver 等查看建表语句. -ACCOUNTS_TABLE_SQL = """ -/* 多平台账号表: 与本地 Chromium/Edge 用户数据目录 (profile) 绑定 */ -CREATE TABLE accounts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键 (自增) - name TEXT NOT NULL, -- 展示名称, 如「搜狐1号」 - platform TEXT NOT NULL, -- 平台标识, 与内置 PLATFORMS 键一致, 如 sohu - phone TEXT, -- 可选绑定手机号 - profile_dir TEXT, -- Playwright 用户数据目录 (绝对路径); 默认可读结构 profiles/<平台展示名>/<手机号>/ - url TEXT, -- 平台入口或登录页 URL - extra_json TEXT, -- 扩展字段 JSON - created_at INTEGER NOT NULL, -- 记录创建时间, Unix 秒 UTC - updated_at INTEGER NOT NULL -- 记录最后更新时间, Unix 秒 UTC -); +# -*- coding: utf-8 -*- """ +Database schema v2 — Credential & Browser Profile Manager. + +Time stored as INTEGER Unix seconds (UTC). DDL comments are preserved in +sqlite_master, visible in DBeaver / Navicat. + +Schema version management: + PRAGMA user_version = 2 (set in connection.py after 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 name;none 时为空 + 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 diff --git a/scripts/service/account_service.py b/scripts/service/account_service.py index 9a00a93..32388e0 100644 --- a/scripts/service/account_service.py +++ b/scripts/service/account_service.py @@ -1,38 +1,147 @@ -"""账号用例:list / get / add / delete / pick-web(含终端输出,供 CLI 调用)。""" +# -*- coding: utf-8 -*- +""" +Account service layer — use cases invoked by CLI commands. + +Each function handles: + 1. Input validation + 2. Logging key nodes + 3. Calling the repository layer + 4. Formatting JSON/stdout output + +Raw secret is NEVER logged or printed in plaintext. +""" import json import os import shutil +import subprocess import sys +import tempfile import time +import uuid +from typing import Optional from db.accounts_repo import ( - count_platform_accounts_conn, - default_name_for_platform, - delete_account_by_id_conn, - delete_account_platform_phone_conn, - delete_accounts_by_platform_conn, - fetch_delete_row_by_id_conn, - fetch_ids_for_list_json, - fetch_ids_profile_for_platform_conn, - fetch_list_rows, - fetch_pick_web_candidate_id, - fetch_row_platform_phone_conn, + acquire_lease, + cleanup_expired_leases, + account_exists_with_login_id, + find_account_ids, + find_accounts, + find_credentials, get_account_by_id, - has_duplicate_phone_conn, - insert_account_row, - normalize_account_id, + get_credential_by_id, + get_platform_from_db, + insert_account, + insert_credential, + list_active_leases, + list_platforms_from_db, + platform_exists, + release_lease, + update_account_error, + update_account_last_used, + update_account_session_status, + update_credential_last_used, ) from db.connection import get_conn, init_db +from util.constants import SKILL_SLUG from util.logging_config import get_skill_logger from util.platforms import ( - PLATFORM_URLS, - _PLATFORM_PRIMARY_CN, - _is_valid_cn_mobile11, - _normalize_phone_digits, + PLATFORMS, _platform_list_cn_for_help, resolve_platform_key, + seed_platforms, ) -from util.runtime_paths import _runtime_paths_debug_text, get_default_profile_dir +from util.runtime_paths import ( + get_default_profile_dir_for_web, + get_skill_data_dir, + _runtime_paths_debug_text, +) +from service.secret_store import ( + mask_secret, + make_windows_credential_target, + read_secret, + write_secret, +) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _map_secret_read_error(exc: BaseException) -> tuple[str, str]: + """Map secret read/write exceptions to ERROR:* codes (no secret text in code).""" + msg = str(exc) + if "SECRET_ENV_MISSING" in msg: + return "ERROR:SECRET_ENV_MISSING", msg + if "WINDOWS_CREDENTIAL_UNAVAILABLE" in msg: + return "ERROR:WINDOWS_CREDENTIAL_UNAVAILABLE", msg + return "ERROR:SECRET_READ_FAILED", msg + + +def _credential_ok_line(payload: dict) -> str: + """Single-line JSON for credential commands (machine integration).""" + return json.dumps({"success": True, **payload}, ensure_ascii=False) + + +def _insert_secret_holder_account( + conn, + *, + platform_key: str, + account_label: str, + environment: str, + role: str, + provider_code: str, + url: Optional[str], + now: int, +) -> int: + """Create a minimal accounts row so credentials can join on environment/role.""" + return insert_account( + conn=conn, + platform_key=platform_key, + account_label=account_label, + login_id=None, + login_id_type="api_account", + tenant_id=None, + environment=environment, + role=role, + provider_code=provider_code, + profile_dir=None, + url=url, + extra_json="{}", + now=now, + ) + + +def _err_json(code: str, message: str) -> str: + """Return a JSON error line.""" + return json.dumps( + {"success": False, "error": {"code": code, "message": message}}, + ensure_ascii=False, + ) + + +def _ok_json(data) -> str: + """Return a JSON success line.""" + return json.dumps( + {"success": True, "data": data}, + ensure_ascii=False, + ) + + +def _say(msg: str) -> None: + """Print to stdout.""" + print(msg) + + +def _say_err(msg: str) -> None: + """Print to stderr.""" + print(msg, file=sys.stderr) + + +def _fail_human(code: str, hint_lines: list[str]) -> None: + """Print human-readable error block.""" + _say(f"ERROR:{code}") + for line in hint_lines: + _say(line) def _remove_profile_dir(path: str) -> None: @@ -42,276 +151,902 @@ def _remove_profile_dir(path: str) -> None: try: shutil.rmtree(p) except OSError as e: - print(f"⚠️ 未能删除用户数据目录(可手工删):{p}\n {e}", file=sys.stderr) + _say_err(f"WARNING: 未能删除用户数据目录(可手工删):{p}\n {e}") -def cmd_list(platform="all", limit: int = 10): - get_skill_logger().info("list filter=%r", platform) +def _resolve_or_fail(input_name: str, hint: str = "") -> Optional[str]: + """Resolve platform key or print ERROR:INVALID_PLATFORM and return None.""" + key = resolve_platform_key((input_name or "").strip()) + if not key: + _say(f"ERROR:INVALID_PLATFORM 无法识别的平台「{input_name}」。") + if hint: + _say(hint) + else: + _say("支持:" + _platform_list_cn_for_help()) + return None + return key + + +# ============================================================================ +# Platform commands +# ============================================================================ + +def cmd_platform_list(domain: Optional[str] = None) -> None: + """platform list [--domain logistics|llm|content]""" + log = get_skill_logger() + log.info("cli_start subcommand=platform_list domain=%s", domain) init_db() - raw = (platform or "all").strip() - if not raw or raw.lower() == "all" or raw == "全部": - key = "all" + conn = get_conn() + try: + seed_platforms(conn) + platforms = list_platforms_from_db(conn, domain=domain) + finally: + conn.close() + _say(_ok_json({"platforms": platforms})) + + +def cmd_platform_get(input_key: str) -> None: + """platform get """ + log = get_skill_logger() + log.info("cli_start subcommand=platform_get key=%s", input_key) + key = _resolve_or_fail(input_key) + if not key: + return + init_db() + conn = get_conn() + try: + plat = get_platform_from_db(conn, key) + finally: + conn.close() + if plat: + _say(_ok_json(plat)) else: - key = resolve_platform_key(raw) - if not key: - get_skill_logger().warning("list_invalid_platform raw=%r", raw) - print(f"ERROR:INVALID_PLATFORM_LIST 无法识别的平台「{raw}」") - print("支持:" + _platform_list_cn_for_help()) + _say(_err_json("ERROR:INVALID_PLATFORM", f"Platform '{key}' not found in DB.")) + + +# ============================================================================ +# Account add-web +# ============================================================================ + +def cmd_account_add_web( + platform_input: str, + login_id: Optional[str], + login_id_type: str, + label: Optional[str], + tenant_id: Optional[str], + environment: str, + role: str, + provider_code: Optional[str], + url: Optional[str], + extra_json_str: Optional[str], + profile_dir_override: Optional[str], +) -> None: + """account add-web — create a web/RPA account record.""" + log = get_skill_logger() + log.info("account_add_web_attempt platform=%s login_id_type=%s env=%s role=%s", + platform_input, login_id_type, environment, role) + + key = _resolve_or_fail(platform_input) + if not key: + return + + init_db() + platform_spec = PLATFORMS.get(key, {}) + now = int(time.time()) + safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {environment} {role}" + extra = {} + if extra_json_str: + try: + extra = json.loads(extra_json_str) + except json.JSONDecodeError: + _say(_err_json("ERROR:CLI_BAD_ARGS", "extra_json 不是合法的 JSON 字符串。")) return - if limit <= 0: - limit = 10 + # Auto-generate or use provided profile_dir + if profile_dir_override: + resolved_profile_dir = os.path.abspath(profile_dir_override) + else: + resolved_profile_dir = get_default_profile_dir_for_web( + platform_key=key, + label=safe_label, + login_id=login_id, + domain=platform_spec.get("domain", "generic"), + ) + os.makedirs(resolved_profile_dir, exist_ok=True) - rows = fetch_list_rows(key, int(limit)) + resolved_url = (url or "").strip() or platform_spec.get("default_url", "") + resolved_provider = provider_code or platform_spec.get("provider_code") or key + resolved_extra = json.dumps(extra, ensure_ascii=False) - found = bool(rows) - if found: - get_skill_logger().info("list_ok rows=%s key=%s", len(rows), key) - sep_line = "_" * 39 - for idx, row in enumerate(rows): - ( - aid, - name, - plat, - phone, - profile_dir, - url, - extra_json, - created_at, - updated_at, - ) = row - print(f"id:{aid}") - print(f"name:{name or ''}") - print(f"platform:{plat or ''}") - print(f"phone:{phone or ''}") - print(f"profile_dir:{profile_dir or ''}") - print(f"url:{url or ''}") - print(f"extra_json:{extra_json or ''}") - print(f"created_at:{int(created_at) if created_at is not None else ''}") - print(f"updated_at:{int(updated_at) if updated_at is not None else ''}") - if idx != len(rows) - 1: - print(sep_line) - print() - - if not found: - print("ERROR:NO_ACCOUNTS_FOUND") - print(_runtime_paths_debug_text(), file=sys.stderr) - - -def cmd_get(account_id): - get_skill_logger().info("get account_id=%r", account_id) - acc = get_account_by_id(account_id) - if acc: - print(json.dumps(acc, ensure_ascii=False)) + conn = get_conn() + try: + seed_platforms(conn) + new_id = insert_account( + conn=conn, + platform_key=key, + account_label=safe_label, + login_id=login_id or None, + login_id_type=login_id_type, + tenant_id=tenant_id or None, + environment=environment, + role=role, + provider_code=resolved_provider, + profile_dir=resolved_profile_dir, + url=resolved_url, + extra_json=resolved_extra, + now=now, + ) + conn.commit() + except Exception: + conn.rollback() + log.exception("account_add_web_failure") + _say(_err_json("ERROR:DB_ERROR", "Database insert failed.")) return - get_skill_logger().warning("get_not_found account_id=%r", account_id) - print("ERROR:ACCOUNT_NOT_FOUND") - print(_runtime_paths_debug_text(), file=sys.stderr) + finally: + conn.close() + + log.info("account_add_web_success id=%s platform=%s profile_dir=%s", new_id, key, resolved_profile_dir) + _say(_ok_json({ + "id": new_id, + "platform_key": key, + "account_label": safe_label, + "profile_dir": resolved_profile_dir, + "url": resolved_url, + "environment": environment, + "role": role, + "tenant_id": tenant_id or "", + "provider_code": resolved_provider, + "status": "active", + "session_status": "unknown", + })) +# ============================================================================ +# Account add-secret +# ============================================================================ + +def cmd_account_add_secret( + platform_input: str, + credential_type: str, + label: Optional[str], + secret_storage: str, + secret_stdin: bool, + secret_ref: Optional[str], + account_id: Optional[int], + environment: Optional[str], + role: Optional[str], + extra_json_str: Optional[str], +) -> None: + """account add-secret — create a credential record with masked secret.""" + log = get_skill_logger() + log.info("account_add_secret_attempt platform=%s type=%s storage=%s", + platform_input, credential_type, secret_storage) + + key = _resolve_or_fail(platform_input) + if not key: + return + + storage_n = secret_storage.strip().lower() + if storage_n not in ("none", "env", "windows_credential"): + _say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED", + f"不支持的存储方式:{secret_storage}。支持:none / env / windows_credential")) + return + + # Validate storage/type combinations + if storage_n == "none" and credential_type not in ("browser_profile", "note"): + _say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED", + "storage=none 仅允许 credential_type=browser_profile 或 note。")) + return + if storage_n == "env" and not secret_ref: + _say(_err_json("ERROR:SECRET_REF_MISSING", + "env 存储时必须提供 --secret-ref 环境变量名。")) + return + + init_db() + platform_spec = PLATFORMS.get(key, {}) + now = int(time.time()) + safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {credential_type}" + + # For env storage: just use the env var name as ref, mask is optional + if storage_n == "env": + secret_ref_value = secret_ref.strip() + secret_mask_value = mask_secret(f"env:{secret_ref_value}") + extra = {} + if extra_json_str: + try: + extra = json.loads(extra_json_str) + except json.JSONDecodeError: + extra = {} + resolved_env = environment or "production" + resolved_role = role or "api" + extra["environment"] = resolved_env + extra["role"] = resolved_role + extra_json_final = json.dumps(extra, ensure_ascii=False) + + resolved_url = (platform_spec.get("default_url") or "").strip() or None + resolved_provider = platform_spec.get("provider_code") or key + + conn = get_conn() + try: + seed_platforms(conn) + holder_id = account_id + if holder_id is None: + holder_id = _insert_secret_holder_account( + conn, + platform_key=key, + account_label=safe_label, + environment=resolved_env, + role=resolved_role, + provider_code=resolved_provider, + url=resolved_url, + now=now, + ) + cred_id = insert_credential( + conn=conn, + account_id=holder_id, + platform_key=key, + credential_label=safe_label, + credential_type=credential_type, + secret_storage=storage_n, + secret_ref=secret_ref_value, + secret_mask=secret_mask_value, + expires_at=None, + extra_json=extra_json_final, + now=now, + ) + conn.commit() + except Exception: + conn.rollback() + log.exception("account_add_secret_failure") + _say(_err_json("ERROR:DB_ERROR", "Database insert failed.")) + return + finally: + conn.close() + + log.info("account_add_secret_success id=%s platform=%s storage=env ref=%s", + cred_id, key, secret_ref_value) + _say(_ok_json({ + "account_id": holder_id, + "credential_id": cred_id, + "platform_key": key, + "credential_label": safe_label, + "credential_type": credential_type, + "secret_storage": storage_n, + "secret_ref": secret_ref_value, + "secret_mask": secret_mask_value, + "warning": "env 存储方式:请确保环境变量在运行时可用。", + })) + return + + # For windows_credential: read secret from stdin + if storage_n == "windows_credential": + if not secret_stdin: + _say(_err_json("ERROR:CLI_BAD_ARGS", + "windows_credential 需要 --secret-stdin 从 stdin 读取 secret。")) + return + # Read secret from stdin (one line) + secret_value = sys.stdin.readline().strip() + if not secret_value: + _say(_err_json("ERROR:CLI_BAD_ARGS", "stdin 中未读到 secret 值。")) + return + + secret_mask_value = mask_secret(secret_value) + cred_label_for_target = safe_label + target = make_windows_credential_target(key, credential_type, cred_label_for_target) + + # Write to Windows Credential Manager + try: + write_secret("windows_credential", target, secret_value) + except (OSError, ValueError) as e: + log.error("secret_write_failed storage=windows_credential error=%s", str(e)) + _say(_err_json("ERROR:SECRET_WRITE_FAILED", f"写 Windows Credential Manager 失败:{e}")) + return + + extra = {} + if extra_json_str: + try: + extra = json.loads(extra_json_str) + except json.JSONDecodeError: + extra = {} + resolved_env = environment or "production" + resolved_role = role or "api" + extra["environment"] = resolved_env + extra["role"] = resolved_role + extra_json_final = json.dumps(extra, ensure_ascii=False) + + resolved_url = (platform_spec.get("default_url") or "").strip() or None + resolved_provider = platform_spec.get("provider_code") or key + + conn = get_conn() + try: + seed_platforms(conn) + holder_id = account_id + if holder_id is None: + holder_id = _insert_secret_holder_account( + conn, + platform_key=key, + account_label=safe_label, + environment=resolved_env, + role=resolved_role, + provider_code=resolved_provider, + url=resolved_url, + now=now, + ) + cred_id = insert_credential( + conn=conn, + account_id=holder_id, + platform_key=key, + credential_label=safe_label, + credential_type=credential_type, + secret_storage=storage_n, + secret_ref=target, + secret_mask=secret_mask_value, + expires_at=None, + extra_json=extra_json_final, + now=now, + ) + conn.commit() + except Exception: + conn.rollback() + log.exception("account_add_secret_failure") + _say(_err_json("ERROR:DB_ERROR", "Database insert failed.")) + return + finally: + conn.close() + + log.info("account_add_secret_success id=%s platform=%s storage=windows_credential", + cred_id, key) + # Clear secret value from memory + secret_value = "" + + _say(_ok_json({ + "account_id": holder_id, + "credential_id": cred_id, + "platform_key": key, + "credential_label": safe_label, + "credential_type": credential_type, + "secret_storage": storage_n, + "secret_ref": target, + "secret_mask": secret_mask_value, + })) + return + + # storage = none (browser_profile / note) + conn = get_conn() + try: + seed_platforms(conn) + cred_id = insert_credential( + conn=conn, + account_id=account_id, + platform_key=key, + credential_label=safe_label, + credential_type=credential_type, + secret_storage=storage_n, + secret_ref=None, + secret_mask="", + expires_at=None, + extra_json=extra_json_str or "{}", + now=now, + ) + conn.commit() + except Exception: + conn.rollback() + log.exception("account_add_secret_failure") + _say(_err_json("ERROR:DB_ERROR", "Database insert failed.")) + return + finally: + conn.close() + + log.info("account_add_secret_success id=%s platform=%s storage=none", cred_id, key) + _say(_ok_json({ + "account_id": account_id, + "credential_id": cred_id, + "platform_key": key, + "credential_label": safe_label, + "credential_type": credential_type, + "secret_storage": storage_n, + "secret_ref": "", + "secret_mask": "", + })) + + +# ============================================================================ +# Account get (single) +# ============================================================================ + +def cmd_account_get(account_id, include_credentials: bool = False) -> None: + """Get a single account by id. Returns JSON.""" + log = get_skill_logger() + log.info("cli_start subcommand=account_get id=%r", account_id) + init_db() + acc = get_account_by_id(account_id) + if not acc: + log.warning("account_get_not_found id=%r", account_id) + _say(f"ERROR:ACCOUNT_NOT_FOUND 账号 id={account_id} 不存在。") + _say_err(_runtime_paths_debug_text()) + return + if include_credentials: + creds = find_credentials( + platform_key=acc["platform_key"], + limit=20, + ) + # Filter to only credentials linked to this account or any for same platform + linked_creds = [c for c in creds if c["account_id"] == acc["id"]] + if not linked_creds: + linked_creds = [c for c in creds if c["account_id"] is None] + acc["credentials"] = [ + { + "id": c["id"], + "credential_label": c["credential_label"], + "credential_type": c["credential_type"], + "secret_storage": c["secret_storage"], + "secret_ref": c["secret_ref"], + "secret_mask": c["secret_mask"], + "status": c["status"], + } + for c in linked_creds + ] + _say(json.dumps(acc, ensure_ascii=False)) + + +# Legacy alias +def cmd_get(account_id) -> None: + """Legacy: get — same as account get .""" + cmd_account_get(account_id, include_credentials=False) + + +# ============================================================================ +# Account list +# ============================================================================ + +def cmd_account_list( + platform_input: Optional[str] = None, + environment: Optional[str] = None, + tenant_id: Optional[str] = None, + role: Optional[str] = None, + limit: int = 200, +) -> None: + """account list — output JSON array of accounts matching filters.""" + log = get_skill_logger() + log.info("cli_start subcommand=account_list platform=%s env=%s tenant=%s role=%s", + platform_input, environment, tenant_id, role) + init_db() + key = None + if platform_input and platform_input not in ("all", "全部", ""): + key = _resolve_or_fail(platform_input) + if not key: + return + + accounts = find_accounts( + platform_key=key, + environment=environment, + tenant_id=tenant_id, + role=role, + limit=limit, + ) + _say(json.dumps(accounts, ensure_ascii=False)) + + +# Legacy alias def cmd_list_json(platform_input: str, limit: int = 200) -> None: - """ - 跨技能接口:stdout 仅输出一行 JSON 数组,元素结构与 get 单条一致。 - 平台可为 all/全部 或各平台中文名/英文键;按 updated_at 倒序,最多 limit 条(上限 500)。 - """ - get_skill_logger().info("list_json filter=%r limit=%s", platform_input, limit) + """Legacy: list-json [--limit N]""" + log = get_skill_logger() + log.info("cli_start subcommand=list_json filter=%r limit=%s", platform_input, limit) init_db() raw = (platform_input or "all").strip() if not raw or raw.lower() == "all" or raw == "全部": - key = "all" + key = None else: - key = resolve_platform_key(raw) + key = _resolve_or_fail(raw) if not key: - print("ERROR:INVALID_PLATFORM_LIST_JSON 无法识别的平台名称。") - print("支持:" + _platform_list_cn_for_help()) return lim = max(1, min(int(limit), 500)) - ids = fetch_ids_for_list_json(key, lim) - out = [] - for aid in ids: - acc = get_account_by_id(aid) - if acc: - out.append(acc) - print(json.dumps(out, ensure_ascii=False)) + accounts = find_accounts(platform_key=key, limit=lim) + _say(json.dumps(accounts, ensure_ascii=False)) -def cmd_pick_web(platform_input: str): - """ - 供 llm-manager 等:取该平台用于网页自动化的账号候选。 - 按 updated_at、created_at 倒序选一条;是否已登录由调用方(如网页自动化)在同一会话内处理。 - 成功:stdout 仅一行 JSON(与 get 一致);失败:首行 ERROR:。 - """ - get_skill_logger().info("pick_web platform_input=%r", platform_input) - key = resolve_platform_key((platform_input or "").strip()) - if not key: - print("ERROR:INVALID_PLATFORM 无法识别的平台名称。") - print("支持:" + _platform_list_cn_for_help()) - return +# ============================================================================ +# Account pick-web +# ============================================================================ - init_db() - picked = fetch_pick_web_candidate_id(key) - - if picked is None: - print("ERROR:NO_ACCOUNT 该平台在账号库中没有任何记录,请先执行 add。") - return - - acc = get_account_by_id(picked) - if not acc: - print("ERROR:ACCOUNT_NOT_FOUND") - print(_runtime_paths_debug_text(), file=sys.stderr) - return - print(json.dumps(acc, ensure_ascii=False)) - - -def cmd_add(platform_input: str, phone: str): - """添加账号:platform 可为中文展示名或英文键;手机号必填;同平台下同手机号不可重复。""" +def cmd_account_pick_web( + platform_input: str, + environment: Optional[str] = None, + tenant_id: Optional[str] = None, + role: Optional[str] = None, + do_lease: bool = False, + ttl_sec: int = 900, + holder: Optional[str] = None, + purpose: Optional[str] = None, +) -> None: + """Pick a web/RPA account for use. Optionally acquire a lease.""" log = get_skill_logger() - log.info("add_attempt platform_input=%r", platform_input) - init_db() - key = resolve_platform_key((platform_input or "").strip()) + log.info("account_pick_web_attempt platform=%s env=%s tenant=%s role=%s lease=%s", + platform_input, environment, tenant_id, role, do_lease) + + key = _resolve_or_fail(platform_input) if not key: - log.warning("add_invalid_platform input=%r", platform_input) - print("ERROR:INVALID_PLATFORM 无法识别的平台名称。") - print("支持:" + _platform_list_cn_for_help()) return - phone_raw = (phone or "").strip() - if not phone_raw: - log.warning("add_phone_missing") - print("ERROR:PHONE_REQUIRED 手机号为必填。") - return - phone_norm = _normalize_phone_digits(phone_raw) - if not _is_valid_cn_mobile11(phone_norm): - log.warning("add_phone_invalid digits_len=%s", len(phone_norm or "")) - print( - "ERROR:PHONE_INVALID 手机号格式不正确:须为中国大陆 11 位号码," - "以 1 开头、第二位为 3~9(示例 13800138000)。" - ) - return - - url = PLATFORM_URLS[key] - now = int(time.time()) - phone_store = phone_norm - + init_db() conn = get_conn() try: - if has_duplicate_phone_conn(conn, key, phone_store): - log.warning("add_duplicate platform=%s phone_suffix=%s", key, phone_store[-4:]) - print( - f"ERROR:DUPLICATE_PHONE_PLATFORM 该平台下已存在手机号 {phone_store},请勿重复添加。" - ) - return - - next_idx = count_platform_accounts_conn(conn, key) + 1 - name = default_name_for_platform(key, next_idx) - profile_dir = get_default_profile_dir(key, phone_store, None) - new_id = insert_account_row(conn, name, key, phone_store, profile_dir, url, now) - conn.commit() + seed_platforms(conn) finally: conn.close() - print( - f"✅ 已保存账号:ID {new_id} | {name} | {_PLATFORM_PRIMARY_CN.get(key, key)} | 手机 {phone_store}" - ) - log.info( - "add_success id=%s platform=%s profile_dir=%s", - new_id, - key, - profile_dir, + candidate_ids = find_account_ids( + platform_key=key, + environment=environment, + tenant_id=tenant_id, + role=role, ) + if not candidate_ids: + _say(_err_json("ERROR:NO_ACCOUNT", + "没有符合条件的可用账号(status=active 且未被其他 lease 占用)。")) + return + picked_id = candidate_ids[0] + acc = get_account_by_id(picked_id) + if not acc: + _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", "选中的账号突然不存在了。")) + return + + # Update last_used_at + update_account_last_used(picked_id) + + result = { + "id": acc["id"], + "platform_key": acc["platform_key"], + "account_label": acc["account_label"], + "login_id": acc["login_id"], + "profile_dir": acc["profile_dir"], + "url": acc["url"], + "tenant_id": acc["tenant_id"], + "environment": acc["environment"], + "role": acc["role"], + "provider_code": acc["provider_code"], + "session_status": acc["session_status"], + } + + # Lease handling + lease_info = None + if do_lease: + lease_token = uuid.uuid4().hex[:32] + resolved_holder = holder or "unknown" + try: + lease_info = acquire_lease( + account_id=picked_id, + lease_token=lease_token, + holder=resolved_holder, + purpose=purpose, + ttl_sec=ttl_sec, + ) + result["lease_token"] = lease_info["lease_token"] + result["lease_expires_at"] = lease_info["expires_at"] + except ValueError as e: + log.warning("account_pick_web_lease_conflict id=%s", picked_id) + _say(_err_json("ERROR:LEASE_CONFLICT", str(e))) + return + + log.info("account_pick_web_success id=%s token_prefix=%s", + picked_id, lease_info["lease_token"][:8] if lease_info else "none") + _say(json.dumps(result, ensure_ascii=False)) + + +# Legacy alias +def cmd_pick_web(platform_input: str) -> None: + """Legacy: pick-web — same as account pick-web without lease.""" + cmd_account_pick_web(platform_input, do_lease=False) + + +# ============================================================================ +# Credential pick / resolve +# ============================================================================ + +def cmd_credential_pick( + platform_input: str, + credential_type: Optional[str] = None, + environment: Optional[str] = None, + role: Optional[str] = None, + reveal: bool = False, +) -> None: + """Pick a credential for a platform, optionally with reveal.""" + log = get_skill_logger() + log.info("cli_start subcommand=credential_pick platform=%s type=%s env=%s role=%s reveal=%s", + platform_input, credential_type, environment, role, reveal) + + key = _resolve_or_fail(platform_input) + if not key: + return + + init_db() + creds = find_credentials( + platform_key=key, + credential_type=credential_type, + environment=environment, + role=role, + status="active", + limit=1, + ) + if not creds: + _say(_err_json("ERROR:CREDENTIAL_NOT_FOUND", + "没有符合条件的可用凭据。")) + return + + cred = creds[0] + update_credential_last_used(cred["id"]) + + result = { + "id": cred["id"], + "platform_key": cred["platform_key"], + "credential_label": cred["credential_label"], + "credential_type": cred["credential_type"], + "secret_storage": cred["secret_storage"], + "secret_ref": cred["secret_ref"], + "secret_mask": cred["secret_mask"], + "status": cred["status"], + } + + if reveal: + try: + secret_value = read_secret(cred["secret_storage"], cred["secret_ref"]) + result["secret"] = secret_value + log.info( + "credential_pick_reveal_success id=%s storage=%s", + cred["id"], + cred["secret_storage"], + ) + except (ValueError, OSError) as e: + code, msg = _map_secret_read_error(e) + log.error("credential_pick_reveal_failure id=%s error=%s", cred["id"], msg) + _say(_err_json(code, msg)) + return + else: + log.info("credential_pick_success id=%s (masked)", cred["id"]) + + _say(_credential_ok_line(result)) + + +def cmd_credential_resolve(credential_id: int, reveal: bool = False) -> None: + """Resolve a credential by id.""" + log = get_skill_logger() + log.info("cli_start subcommand=credential_resolve id=%s reveal=%s", credential_id, reveal) + + init_db() + cred = get_credential_by_id(credential_id) + if not cred: + _say(_err_json("ERROR:CREDENTIAL_NOT_FOUND", f"凭据 id={credential_id} 不存在。")) + return + if cred["status"] != "active": + _say(_err_json("ERROR:CREDENTIAL_DISABLED", f"凭据 id={credential_id} 状态为 {cred['status']},不可用。")) + return + + result = { + "id": cred["id"], + "account_id": cred["account_id"], + "platform_key": cred["platform_key"], + "credential_label": cred["credential_label"], + "credential_type": cred["credential_type"], + "secret_storage": cred["secret_storage"], + "secret_ref": cred["secret_ref"], + "secret_mask": cred["secret_mask"], + "status": cred["status"], + } + + if reveal: + try: + secret_value = read_secret(cred["secret_storage"], cred["secret_ref"]) + result["secret"] = secret_value + log.info("credential_resolve_reveal_success id=%s", credential_id) + except (ValueError, OSError) as e: + code, msg = _map_secret_read_error(e) + log.error("credential_resolve_reveal_failure id=%s error=%s", credential_id, msg) + _say(_err_json(code, msg)) + return + else: + log.info("credential_resolve_success id=%s (masked)", credential_id) + + _say(_credential_ok_line(result)) + + +# ============================================================================ +# Lease commands +# ============================================================================ + +def cmd_lease_release(lease_token: str) -> None: + """lease release """ + log = get_skill_logger() + log.info("cli_start subcommand=lease_release token_prefix=%s", lease_token[:8]) + ok = release_lease(lease_token) + if ok: + _say(_ok_json({"lease_token": lease_token, "status": "released"})) + else: + _say(_err_json("ERROR:LEASE_NOT_FOUND", "未找到活跃的租约,或租约已释放/过期。")) + + +def cmd_lease_list(active_only: bool = False) -> None: + """lease list [--active]""" + log = get_skill_logger() + log.info("cli_start subcommand=lease_list active_only=%s", active_only) + leases = list_active_leases() + if active_only: + # Already filtered in list_active_leases + pass + _say(_ok_json({"leases": leases})) + + +def cmd_lease_cleanup() -> None: + """lease cleanup — mark expired leases as expired.""" + log = get_skill_logger() + log.info("cli_start subcommand=lease_cleanup") + count = cleanup_expired_leases() + _say(_ok_json({"cleaned": count})) + + +# ============================================================================ +# Account mark commands +# ============================================================================ + +def cmd_account_mark_session(account_id: int, session_status: str) -> None: + """account mark-session --session-status """ + log = get_skill_logger() + log.info("account_mark_session id=%s status=%s", account_id, session_status) + valid_statuses = ("unknown", "likely_valid", "needs_login", "expired") + if session_status not in valid_statuses: + _say(_err_json("ERROR:CLI_BAD_ARGS", + f"session_status 必须为 {valid_statuses} 之一。")) + return + acc = get_account_by_id(account_id) + if not acc: + _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。")) + return + update_account_session_status(account_id, session_status) + _say(_ok_json({"id": account_id, "session_status": session_status})) + + +def cmd_account_mark_error(account_id: int, error_code: str, error_message: str) -> None: + """account mark-error --code --message """ + log = get_skill_logger() + log.info("account_mark_error id=%s code=%s", account_id, error_code) + acc = get_account_by_id(account_id) + if not acc: + _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。")) + return + update_account_error(account_id, error_code, error_message) + _say(_ok_json({"id": account_id, "error_code": error_code})) + + +def cmd_account_mark_used(account_id: int) -> None: + """account mark-used """ + log = get_skill_logger() + log.info("account_mark_used id=%s", account_id) + acc = get_account_by_id(account_id) + if not acc: + _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。")) + return + update_account_last_used(account_id) + _say(_ok_json({"id": account_id, "marked": "used"})) + + +# ============================================================================ +# Legacy delete commands (kept for compatibility) +# ============================================================================ def cmd_delete_by_id(account_id) -> None: + """delete id — legacy""" log = get_skill_logger() - log.info("delete_by_id account_id=%r", account_id) - init_db() - aid = normalize_account_id(account_id) - if aid is None or (isinstance(aid, str) and not str(aid).isdigit()): - log.warning("delete_invalid_id") - print("ERROR:DELETE_INVALID_ID 账号 id 须为正整数。") + log.info("delete_by_id id=%r", account_id) + acc = get_account_by_id(account_id) + if not acc: + _say(f"ERROR:ACCOUNT_NOT_FOUND 账号 id={account_id} 不存在。") + _say_err(_runtime_paths_debug_text()) return + profile_dir = acc.get("profile_dir", "") conn = get_conn() try: - row = fetch_delete_row_by_id_conn(conn, int(aid)) - if not row: - log.warning("delete_by_id_not_found id=%s", aid) - print("ERROR:ACCOUNT_NOT_FOUND") - print(_runtime_paths_debug_text(), file=sys.stderr) - return - rid, name, plat, phone, profile_dir = row - delete_account_by_id_conn(conn, rid) + cur = conn.cursor() + cur.execute("DELETE FROM accounts WHERE id = ?", (int(account_id),)) + # Also delete linked credentials + cur.execute("DELETE FROM credentials WHERE account_id = ?", (int(account_id),)) conn.commit() finally: conn.close() _remove_profile_dir(profile_dir) - print( - f"✅ 已删除账号:ID {rid} | {name} | {_PLATFORM_PRIMARY_CN.get(plat, plat)} | 手机 {phone or '(无)'}" - ) - log.info("delete_by_id_done id=%s platform=%s", rid, plat) + log.info("delete_by_id_done id=%s", account_id) + _say(f"✅ 已删除账号 ID {account_id}。") def cmd_delete_by_platform(platform_input: str) -> None: + """delete platform — legacy""" log = get_skill_logger() - log.info("delete_by_platform platform_input=%r", platform_input) - init_db() - key = resolve_platform_key((platform_input or "").strip()) + log.info("delete_by_platform platform=%r", platform_input) + key = _resolve_or_fail(platform_input) if not key: - log.warning("delete_by_platform_invalid_platform") - print("ERROR:INVALID_PLATFORM 无法识别的平台名称。") - print("支持:" + _platform_list_cn_for_help()) return conn = get_conn() try: - rows = fetch_ids_profile_for_platform_conn(conn, key) + cur = conn.cursor() + cur.execute("SELECT id, profile_dir FROM accounts WHERE platform_key = ?", (key,)) + rows = cur.fetchall() if not rows: - log.warning("delete_by_platform_empty platform=%s", key) - print("ERROR:NO_ACCOUNTS_FOUND 该平台下没有账号记录。") + _say("ERROR:NO_ACCOUNTS_FOUND 该平台下没有账号记录。") return - delete_accounts_by_platform_conn(conn, key) + for rid, pdir in rows: + _remove_profile_dir(pdir) + cur.execute("DELETE FROM accounts WHERE platform_key = ?", (key,)) + cur.execute( + "DELETE FROM credentials WHERE platform_key = ? AND account_id IS NOT NULL", + (key,), + ) conn.commit() + log.info("delete_by_platform_done platform=%s count=%s", key, len(rows)) + _say(f"✅ 已删除 {PLATFORMS.get(key, {}).get('display_name', key)} 下共 {len(rows)} 条账号。") finally: conn.close() - for _rid, pdir in rows: - _remove_profile_dir(pdir) - print( - f"✅ 已删除 {_PLATFORM_PRIMARY_CN.get(key, key)} 下共 {len(rows)} 条账号及对应用户数据目录。" - ) - log.info("delete_by_platform_done platform=%s count=%s", key, len(rows)) def cmd_delete_by_platform_phone(platform_input: str, phone: str) -> None: + """Legacy: delete by platform+phone — now uses login_id matching""" log = get_skill_logger() - log.info("delete_by_platform_phone platform_input=%r", platform_input) - init_db() - key = resolve_platform_key((platform_input or "").strip()) + log.info("delete_by_platform_phone platform=%r", platform_input) + key = _resolve_or_fail(platform_input) if not key: - print("ERROR:INVALID_PLATFORM 无法识别的平台名称。") - print("支持:" + _platform_list_cn_for_help()) - return - phone_raw = (phone or "").strip() - if not phone_raw: - print("ERROR:PHONE_REQUIRED 手机号为必填。") - return - phone_norm = _normalize_phone_digits(phone_raw) - if not _is_valid_cn_mobile11(phone_norm): - print( - "ERROR:PHONE_INVALID 手机号格式不正确:须为中国大陆 11 位号码," - "以 1 开头、第二位为 3~9。" - ) return + phone_norm = "".join(c for c in phone if c.isdigit()) conn = get_conn() try: - row = fetch_row_platform_phone_conn(conn, key, phone_norm) + cur = conn.cursor() + cur.execute( + "SELECT id, profile_dir FROM accounts WHERE platform_key = ? AND login_id = ?", + (key, phone_norm), + ) + row = cur.fetchone() if not row: - print("ERROR:ACCOUNT_NOT_FOUND 该平台下无此手机号。") + _say("ERROR:ACCOUNT_NOT_FOUND 该平台下无此手机号。") return - rid, name, profile_dir = row - delete_account_platform_phone_conn(conn, key, phone_norm) + rid, pdir = row + _remove_profile_dir(pdir) + cur.execute("DELETE FROM accounts WHERE id = ?", (rid,)) + cur.execute("DELETE FROM credentials WHERE account_id = ?", (rid,)) conn.commit() + log.info("delete_by_platform_phone_done id=%s", rid) + _say(f"✅ 已删除账号 ID {rid}。") finally: conn.close() - _remove_profile_dir(profile_dir) - print( - f"✅ 已删除账号:ID {rid} | {name} | {_PLATFORM_PRIMARY_CN.get(key, key)} | 手机 {phone_norm}" + + +# ============================================================================ +# Legacy list (human readable) +# ============================================================================ + +def cmd_list(platform="all", limit: int = 10) -> None: + """Legacy: human-readable list output.""" + log = get_skill_logger() + log.info("list filter=%r", platform) + init_db() + accounts = find_accounts( + platform_key=None if (not platform or platform in ("all", "全部")) else platform, + limit=limit, ) - log.info("delete_by_platform_phone_done id=%s platform=%s", rid, key) + if not accounts: + _say("ERROR:NO_ACCOUNTS_FOUND") + _say_err(_runtime_paths_debug_text()) + return + log.info("list_ok rows=%s", len(accounts)) + for i, acc in enumerate(accounts): + _say(f"id:{acc['id']}") + _say(f"label:{acc['account_label']}") + _say(f"platform:{acc['platform_key']}") + _say(f"login_id:{acc['login_id']}") + _say(f"env:{acc['environment']} role:{acc['role']}") + _say(f"status:{acc['status']} session:{acc['session_status']}") + _say(f"profile_dir:{acc['profile_dir']}") + _say(f"url:{acc['url']}") + _say(f"created_at:{acc['created_at']}") + _say(f"updated_at:{acc['updated_at']}") + if acc.get("last_error_code"): + _say(f"last_error:{acc['last_error_code']} {acc.get('last_error_message', '')}") + if i < len(accounts) - 1: + _say("_______________________________________") + _say("") diff --git a/scripts/service/browser_service.py b/scripts/service/browser_service.py index 50fc8ba..f205ca3 100644 --- a/scripts/service/browser_service.py +++ b/scripts/service/browser_service.py @@ -1,21 +1,28 @@ -"""Chromium/Edge 检测与 Playwright:仅打开浏览器(不写库、不做登录态 DOM 检测)。""" +# -*- 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 PLATFORM_URLS +from util.platforms import PLATFORMS # 子进程入口脚本与本模块同目录(独立解释器执行,非 import) _SERVICE_DIR = os.path.dirname(os.path.abspath(__file__)) -# PyArmor 在模块首行注入对 pyarmor_runtime_* 的 import;若以「脚本路径」启动子进程, -# sys.path[0] 仅为 service/,找不到 scripts/ 下的运行时包。必须用 -m 且 cwd=scripts/(标准做法)。 _SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR) @@ -68,11 +75,17 @@ def _print_browser_install_hint(): def cmd_open(account_id): - """打开该账号的持久化浏览器,仅用于肉眼核对;不写数据库、不判定是否已登录。""" - get_skill_logger().info("open account_id=%r", account_id) + """ + 打开该账号的持久化浏览器,仅用于肉眼核对。 + 不写数据库、不判定是否已登录。 + """ + log = get_skill_logger() + init_db() + log.info("browser_open_start account_id=%r", account_id) + target = get_account_by_id(account_id) if not target: - get_skill_logger().warning("open_not_found account_id=%r", account_id) + log.warning("browser_open_error account_id=%r not_found", account_id) print("ERROR:ACCOUNT_NOT_FOUND") return @@ -85,23 +98,33 @@ def cmd_open(account_id): import playwright # noqa: F401 except ImportError: print( - "ERROR:需要 playwright Python 包:请在匠厂共享环境中已含 playwright;" - "本机需安装 Google Chrome 或 Microsoft Edge(channel 模式,无需 playwright install chromium)" + "ERROR:需要 playwright Python 包。" ) return profile_dir = (target.get("profile_dir") or "").strip() if not profile_dir: - print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。") + log.warning("browser_open_error account_id=%s profile_dir_missing", account_id) + print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。") return os.makedirs(profile_dir, exist_ok=True) - url = (target.get("url") or "").strip() or PLATFORM_URLS.get( - target["platform"], "https://www.google.com" + + # 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['name']}] 的 {browser_name}(仅查看,不写入数据库)") + print(f"正在打开 [{target['account_label']}] 的 {browser_name}(仅查看,不写入数据库)") print(f"地址:{url}") - print("是否已登录由业务侧(如大模型网页引擎)在使用账号时自行处理;本命令不做登录检测。") + print("本命令不做登录检测。") cfg = {"channel": channel, "profile_dir": profile_dir, "url": url} with tempfile.NamedTemporaryFile( @@ -115,6 +138,10 @@ def cmd_open(account_id): 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) diff --git a/scripts/service/secret_store.py b/scripts/service/secret_store.py new file mode 100644 index 0000000..3e52249 --- /dev/null +++ b/scripts/service/secret_store.py @@ -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}") diff --git a/scripts/util/platforms.py b/scripts/util/platforms.py index f61de2a..d34ead1 100644 --- a/scripts/util/platforms.py +++ b/scripts/util/platforms.py @@ -1,145 +1,341 @@ -"""平台配置、别名解析、手机号校验。""" -import re +# -*- coding: utf-8 -*- +""" +Platform definitions and resolution. -# 平台唯一配置表:只改这里即可。键 = 入库的 platform;label = 帮助/提示里的展示名; -# prefix = 默认账号名「{prefix}{序号}号」,省略时等于 label; -# aliases = 额外 CLI 称呼(英文键与 label 已自动参与解析,不必重复写)。 -# 登录检测:仅看页面 DOM。匹配 anchor 的标签页上出现「未登录」选择器则未登录;否则视为已登录。 -# - _LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS:通用「登录」按钮/链接等(可被 login_skip_generic_logged_out_dom 关闭)。 -# - login_logged_out_selectors:按平台追加;误判时也可用 login_skip_generic_logged_out_dom + 仅平台自选。 -PLATFORMS = { - "sohu": { - "url": "https://mp.sohu.com", - "label": "搜狐号", - "prefix": "搜狐", - "aliases": ["搜狐"], +Central authoritative registry for all platforms (logistics, LLM, content, etc.). +Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms(). +""" +import json +import time +from typing import Any, Optional + +from util.logging_config import get_skill_logger + +# --------------------------------------------------------------------------- +# Platform specification fields: +# display_name – Human-readable name (e.g. "Maersk") +# domain – Business domain: logistics / llm / content / ecommerce / generic +# provider_code – Supplier code (logistics); can be same as key +# default_url – Default entry URL (empty string if none) +# aliases – List of name aliases (Chinese/English, for CLI resolution) +# capabilities – Dict of capability flags: +# {"web": bool, "api_key": bool, "rpa": bool} +# --------------------------------------------------------------------------- + +PLATFORMS: dict[str, dict[str, Any]] = { + # ========================================================================= + # Logistics platforms + # ========================================================================= + "maersk": { + "display_name": "Maersk", + "domain": "logistics", + "provider_code": "maersk", + "default_url": "https://www.maersk.com", + "aliases": ["马士基", "maersk", "Maersk"], + "capabilities": {"web": True, "api_key": False, "rpa": True}, }, - "toutiao": { - "url": "https://mp.toutiao.com/", - "label": "头条号", - "prefix": "头条", - "aliases": ["头条"], + "cosco": { + "display_name": "COSCO", + "domain": "logistics", + "provider_code": "cosco", + "default_url": "", + "aliases": ["中远海运", "COSCO", "cosco"], + "capabilities": {"web": True, "api_key": False, "rpa": True}, }, - "zhihu": { - "url": "https://www.zhihu.com", - "label": "知乎", - "aliases": ["知乎号"], + "msc": { + "display_name": "MSC", + "domain": "logistics", + "provider_code": "msc", + "default_url": "", + "aliases": ["MSC", "地中海航运"], + "capabilities": {"web": True, "api_key": False, "rpa": True}, }, - "wechat": { - "url": "https://mp.weixin.qq.com", - "label": "微信公众号", - "prefix": "微信", - "aliases": ["公众号", "微信"], + "cma_cgm": { + "display_name": "CMA CGM", + "domain": "logistics", + "provider_code": "cma_cgm", + "default_url": "", + "aliases": ["达飞", "CMA CGM", "cma cgm"], + "capabilities": {"web": True, "api_key": False, "rpa": True}, }, - "kimi": { - "url": "https://kimi.moonshot.cn", - "label": "Kimi", - "aliases": ["月之暗面"], + "evergreen": { + "display_name": "Evergreen", + "domain": "logistics", + "provider_code": "evergreen", + "default_url": "", + "aliases": ["长荣", "Evergreen", "evergreen"], + "capabilities": {"web": True, "api_key": False, "rpa": True}, }, + "cargo_wise": { + "display_name": "CargoWise", + "domain": "logistics", + "provider_code": "cargo_wise", + "default_url": "", + "aliases": ["CargoWise", "Cargo Wise", "cargo wise"], + "capabilities": {"web": True, "api_key": False, "rpa": True}, + }, + "freightos": { + "display_name": "Freightos", + "domain": "logistics", + "provider_code": "freightos", + "default_url": "", + "aliases": ["Freightos", "freightos"], + "capabilities": {"web": True, "api_key": True, "rpa": False}, + }, + "webcargo": { + "display_name": "WebCargo", + "domain": "logistics", + "provider_code": "webcargo", + "default_url": "", + "aliases": ["WebCargo", "web cargo"], + "capabilities": {"web": True, "api_key": True, "rpa": False}, + }, + "sinotrans": { + "display_name": "Sinotrans", + "domain": "logistics", + "provider_code": "sinotrans", + "default_url": "", + "aliases": ["中外运", "Sinotrans", "sinotrans"], + "capabilities": {"web": True, "api_key": False, "rpa": True}, + }, + # ========================================================================= + # LLM / AI platforms + # ========================================================================= "deepseek": { - "url": "https://chat.deepseek.com", - "label": "DeepSeek", + "display_name": "DeepSeek", + "domain": "llm", + "provider_code": "deepseek", + "default_url": "https://chat.deepseek.com", + "aliases": ["DeepSeek", "deepseek"], + "capabilities": {"web": True, "api_key": True, "rpa": False}, }, "doubao": { - "url": "https://www.doubao.com", - "label": "豆包", + "display_name": "Doubao", + "domain": "llm", + "provider_code": "doubao", + "default_url": "https://www.doubao.com", + "aliases": ["豆包", "Doubao"], + "capabilities": {"web": True, "api_key": True, "rpa": False}, + }, + "kimi": { + "display_name": "Kimi", + "domain": "llm", + "provider_code": "kimi", + "default_url": "https://kimi.moonshot.cn", + "aliases": ["Kimi", "月之暗面"], + "capabilities": {"web": True, "api_key": True, "rpa": False}, }, "qianwen": { - "url": "https://tongyi.aliyun.com", - "label": "通义千问", - "prefix": "通义", - "aliases": ["通义", "千问"], + "display_name": "Qianwen", + "domain": "llm", + "provider_code": "qianwen", + "default_url": "https://tongyi.aliyun.com", + "aliases": ["通义千问", "通义", "千问", "Qianwen"], + "capabilities": {"web": True, "api_key": True, "rpa": False}, }, "yiyan": { - "url": "https://yiyan.baidu.com", - "label": "文心一言", - "prefix": "文心", - "aliases": ["文心", "一言"], + "display_name": "YiYan", + "domain": "llm", + "provider_code": "yiyan", + "default_url": "https://yiyan.baidu.com", + "aliases": ["文心一言", "文心", "一言", "YiYan"], + "capabilities": {"web": True, "api_key": True, "rpa": False}, }, "yuanbao": { - "url": "https://yuanbao.tencent.com", - "label": "腾讯元宝", - "prefix": "元宝", - "aliases": ["元宝"], + "display_name": "Yuanbao", + "domain": "llm", + "provider_code": "yuanbao", + "default_url": "https://yuanbao.tencent.com", + "aliases": ["腾讯元宝", "元宝", "Yuanbao"], + "capabilities": {"web": True, "api_key": True, "rpa": False}, }, "gemini": { - "url": "https://gemini.google.com", - "label": "Gemini", - "prefix": "Gemini", - "aliases": ["谷歌Gemini", "Google Gemini", "google gemini", "Bard"], + "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}, }, } -# 未登录页共性:主行动点含「登录」——submit 按钮、Element 主按钮、Semi 文案、标题、通栏主按钮等,Playwright :has-text 可覆盖多数站点。 -_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS = ( - 'button:has-text("登录")', - 'role=button[name="登录"]', - 'a:has-text("登录")', - 'h4:has-text("登录")', - 'span.semi-button-content:has-text("登录")', -) - - -def _build_platform_derived(): - urls = {} - name_prefix = {} - primary_cn = {} - alias_to_key = {} - - def _register_alias(alias: str, key: str) -> None: - if not alias or not str(alias).strip(): - return - a = str(alias).strip() - if a not in alias_to_key: - alias_to_key[a] = key - lo = a.lower() - if lo not in alias_to_key: - alias_to_key[lo] = key +# --------------------------------------------------------------------------- +# Derived alias map +# --------------------------------------------------------------------------- +def _build_alias_map() -> dict[str, str]: + """Build a case-insensitive flat alias -> key map.""" + m: dict[str, str] = {} for key, spec in PLATFORMS.items(): - urls[key] = spec["url"] - primary_cn[key] = spec["label"] - name_prefix[key] = (spec.get("prefix") or spec["label"]).strip() or key - _register_alias(key, key) - _register_alias(spec["label"], key) - for a in spec.get("aliases") or []: - _register_alias(a, key) - - return urls, name_prefix, primary_cn, alias_to_key + # Register the key itself (lowercase) + m[key.lower()] = key + # Register all aliases (original + lowercase) + for alias in [spec["display_name"]] + spec.get("aliases", []): + a = str(alias).strip() + if a: + m[a] = key + m[a.lower()] = key + return m -PLATFORM_URLS, _PLATFORM_NAME_CN, _PLATFORM_PRIMARY_CN, _PLATFORM_ALIAS_TO_KEY = _build_platform_derived() +_PLATFORM_ALIAS_MAP = _build_alias_map() -def resolve_platform_key(name: str): - """将用户输入解析为内部 platform 键;无法识别返回 None。""" - if name is None: +def resolve_platform_key(name: str) -> Optional[str]: + """ + Resolve user input to a canonical platform key. + Supports: key, display_name, any alias. Case-insensitive. + Returns None if unrecognized. + """ + if not name: return None s = str(name).strip() if not s: return None sl = s.lower() - if sl in PLATFORM_URLS: + # Direct key match + if sl in PLATFORMS: return sl - return _PLATFORM_ALIAS_TO_KEY.get(s) + # Alias map + if sl in _PLATFORM_ALIAS_MAP: + return _PLATFORM_ALIAS_MAP[sl] + if s in _PLATFORM_ALIAS_MAP: + return _PLATFORM_ALIAS_MAP[s] + return None -def _normalize_phone_digits(phone: str) -> str: - """从输入中提取数字串,供号段规则校验与入库去重(不对外承诺可随意夹杂符号)。""" - d = re.sub(r"\D", "", (phone or "").strip()) - if d.startswith("86") and len(d) >= 13: - d = d[2:] - return d +def get_platform(key: str) -> Optional[dict[str, Any]]: + """Return the full platform spec dict for a validated key.""" + return PLATFORMS.get(key) -def _is_valid_cn_mobile11(digits: str) -> bool: - """中国大陆 11 位手机号:1 开头,第二位 3–9,共 11 位数字。""" - return bool(re.fullmatch(r"1[3-9]\d{9}", digits or "")) +def list_platforms(domain: Optional[str] = None) -> list[dict[str, Any]]: + """List all platforms, optionally filtered by domain. + Returns a list of dicts with metadata.""" + result = [] + for key, spec in PLATFORMS.items(): + if domain and spec["domain"] != domain: + continue + result.append({ + "platform_key": key, + "display_name": spec["display_name"], + "domain": spec["domain"], + "provider_code": spec.get("provider_code"), + "default_url": spec.get("default_url", ""), + "aliases": spec.get("aliases", []), + "capabilities": spec.get("capabilities", {}), + "enabled": True, + }) + return result + + +def seed_platforms(conn) -> None: + """ + Upsert all platform definitions into the 'platforms' table. + Must be called after init_db() in the same connection lifecycle. + """ + 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: - """帮助文案:一行中文展示名(不重复内部键)。""" - parts = [] - for k in sorted(PLATFORM_URLS.keys()): - parts.append(_PLATFORM_PRIMARY_CN.get(k, k)) - return "、".join(parts) + """帮助文案:一行展示名,供 CLI 用法提示。""" + return "、".join( + spec["display_name"] for _k, spec in sorted( + PLATFORMS.items(), key=lambda x: x[1]["display_name"] + ) + ) diff --git a/scripts/util/runtime_paths.py b/scripts/util/runtime_paths.py index bf855e9..540a3c8 100644 --- a/scripts/util/runtime_paths.py +++ b/scripts/util/runtime_paths.py @@ -1,4 +1,7 @@ -"""数据目录、profile 路径、CLI 路径调试输出。""" +# -*- coding: utf-8 -*- +""" +Data directory, profile path, CLI path debugging output. +""" import os import re import sys @@ -6,21 +9,20 @@ from typing import Optional from jiangchang_skill_core.runtime_env import get_data_root, get_user_id from util.constants import SKILL_SLUG -from util.platforms import _PLATFORM_PRIMARY_CN -def get_skill_data_dir(): +def get_skill_data_dir() -> str: path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG) os.makedirs(path, exist_ok=True) return path -def get_db_path(): +def get_db_path() -> str: return os.path.join(get_skill_data_dir(), "account-manager.db") -def _runtime_paths_debug_text(): - """供排查「为何 list 没数据」:终端未注入环境变量时会落到 _anon 等默认目录,与网关里用的库不是同一个文件。""" +def _runtime_paths_debug_text() -> str: + """For debugging: print current data path info to stderr.""" env_root = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() or "(未设置)" env_uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "(未设置→使用 _anon)" return ( @@ -33,8 +35,8 @@ def _runtime_paths_debug_text(): ) -def _maybe_print_paths_debug_cli(): - """设置 JIANGCHANG_ACCOUNT_DEBUG_PATHS=1 时,每次执行子命令前在 stderr 打印路径。""" +def _maybe_print_paths_debug_cli() -> None: + """Set JIANGCHANG_ACCOUNT_DEBUG_PATHS=1 to print paths in stderr.""" v = (os.getenv("JIANGCHANG_ACCOUNT_DEBUG_PATHS") or "").strip().lower() if v in ("1", "true", "yes", "on"): print(_runtime_paths_debug_text(), file=sys.stderr) @@ -49,7 +51,7 @@ _WIN_RESERVED_NAMES = frozenset( def _fs_safe_segment(name: str, fallback: str) -> str: - """单级目录名:去掉 Windows 非法字符,避免末尾点/空格;空则用 fallback。""" + """Single directory segment: strip Windows-unsafe chars; empty -> fallback.""" t = _WIN_FS_FORBIDDEN.sub("_", (name or "").strip()) t = t.rstrip(" .") if not t: @@ -59,22 +61,33 @@ def _fs_safe_segment(name: str, fallback: str) -> str: return t -def get_default_profile_dir( - platform_key: str, phone: Optional[str], account_id: Optional[int] = None +def get_default_profile_dir_for_web( + platform_key: str, + label: str, + login_id: Optional[str] = None, + domain: str = "generic", ) -> str: """ - 默认可读路径:profiles/<平台展示名>/<手机号>/(便于在资源管理器中辨认)。 - 无手机号时退化为 profiles/<平台展示名>/no_phone_/。 + Generate a browser profile directory path. + + Structure: + {DATA_ROOT}/{USER_ID}/account-manager/profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix]/ + + This keeps profiles organized by domain and platform for easy + identification in file explorer. """ - label = _PLATFORM_PRIMARY_CN.get(platform_key, platform_key or "account") - label_seg = _fs_safe_segment(label, _fs_safe_segment(platform_key or "", "platform")) - ph = (phone or "").strip() - if ph: - phone_seg = _fs_safe_segment(ph, f"id_{account_id}" if account_id is not None else "phone") - else: - phone_seg = _fs_safe_segment( - "", f"no_phone_{account_id}" if account_id is not None else "no_phone" - ) - path = os.path.join(get_skill_data_dir(), "profiles", label_seg, phone_seg) + label_seg = _fs_safe_segment(label, platform_key) + login_suffix = "" + if login_id: + safe_login = _fs_safe_segment(login_id, "") + if safe_login: + login_suffix = f"_{safe_login[:16]}" + path = os.path.join( + get_skill_data_dir(), + "profiles", + _fs_safe_segment(domain, "generic"), + _fs_safe_segment(platform_key, "unknown"), + f"{label_seg}{login_suffix}", + ) os.makedirs(path, exist_ok=True) return path diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..a6007a8 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# account-manager tests diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bb2cd2f --- /dev/null +++ b/tests/conftest.py @@ -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() diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100644 index 0000000..a009070 --- /dev/null +++ b/tests/run_tests.py @@ -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)) diff --git a/tests/test_account_service.py b/tests/test_account_service.py new file mode 100644 index 0000000..4c130d6 --- /dev/null +++ b/tests/test_account_service.py @@ -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" diff --git a/tests/test_browser_open.py b/tests/test_browser_open.py new file mode 100644 index 0000000..735df62 --- /dev/null +++ b/tests/test_browser_open.py @@ -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") diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 0000000..c19b802 --- /dev/null +++ b/tests/test_credentials.py @@ -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}" diff --git a/tests/test_lease.py b/tests/test_lease.py new file mode 100644 index 0000000..1beeb7f --- /dev/null +++ b/tests/test_lease.py @@ -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" diff --git a/tests/test_platforms.py b/tests/test_platforms.py new file mode 100644 index 0000000..6556496 --- /dev/null +++ b/tests/test_platforms.py @@ -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"