Compare commits

..

6 Commits

Author SHA1 Message Date
097dde0ebb fix: open account browser maximized without Chrome first-run
All checks were successful
技能自动化发布 / release (push) Successful in 5s
Human open now uses start-maximized and no-first-run flags, and profile shortcuts share the same args plus the platform URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 14:11:05 +08:00
718aeefd44 fix: unify row open labels and silent success
All checks were successful
技能自动化发布 / release (push) Successful in 5s
Rename 打开文件位置 to 打开目录, and print empty SAS message so the host skips 执行成功 after opening Explorer or the browser.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 13:15:26 +08:00
123195fcd9 fix: align add-web form fields to column names for lookup
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-31 15:14:31 +08:00
ba991a2462 feat: bind accounts create to add-web via replaces
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-31 14:41:36 +08:00
e9455cbb86 feat: data-management lookup for platform/account fields
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-31 13:37:07 +08:00
87877010ac fix: account open launches Chrome/Edge directly like reveal-profile
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-31 11:58:18 +08:00
14 changed files with 398 additions and 179 deletions

View File

@@ -1,7 +1,7 @@
--- ---
name: 账号与凭据管理 name: 账号与凭据管理
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。" description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。"
version: 2.0.16 version: 2.0.22
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:
@@ -28,14 +28,14 @@ allowed-tools:
## 何时使用本技能 ## 何时使用本技能
1. **安装/升级后初始化库与数据管理元数据**:`init-db`(宿主会自动调用;也可手工执行) 1. **安装/升级后初始化库与数据管理元数据**:`init-db`(宿主会自动调用;也可手工执行)
2. **登记网页/RPA 账号**:`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir;可选 `--auth-strategy` / `--session-persistent` / `--device-fingerprint`。 2. **登记网页/RPA 账号**:`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir;可选 `--auth-strategy` / `--session-persistent` / `--device-fingerprint`。数据管理「新增」已绑定该能力(`replaces: create`),勿用宿主直写库新建账号。
3. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。 3. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。
4. **查看/列出账号**:`account get/list` — JSON 输出;list 可按 `--login-id` / `--label` 筛选。 4. **查看/列出账号**:`account get/list` — JSON 输出;list 可按 `--login-id` / `--label` 筛选。
5. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。 5. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。
6. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。 6. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。
7. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。 7. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
8. **浏览器打开查看**:`account open <id>`(或兼容别名 `open <id>`)— 拉起 Profile 后立即返回,不做站点侧登录判定。数据管理「账号信息」行内 **打开浏览器** / **打开文件位置** 均为 `interaction: direct` + `sync`(见 `assets/actions.json`)。 8. **打开浏览器**:`account open <id>`(或兼容别名 `open <id>`)— **本进程**直接拉起系统 Chrome/Edge(独立 `--user-data-dir`),最大化并跳过 Chrome 首次欢迎页,直接打开平台链接;与「打开目录」同为一点即走,不做登录判定。数据管理行内按钮均为 `interaction: direct` + `sync`,成功不弹提示。
9. **打开 Profile 目录**:`account reveal-profile <id>` — 在系统文件管理器中打开该账号 `profile_dir`。 9. **打开目录**:`account reveal-profile <id>` — 在系统文件管理器中打开该账号 `profile_dir`。
10. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。 10. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。
11. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。 11. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。
12. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。 12. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。

View File

@@ -1,6 +1,6 @@
# assets # assets
- `actions.json`:匠厂宿主 Skill Action 清单(数据管理「账号信息」行内:打开文件位置 / 打开浏览器)。 - `actions.json`:匠厂宿主 Skill Action 清单(数据管理「账号信息」行内:打开目录 / 打开浏览器)。
- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。 - `examples/`:CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/`:`get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。 - `schemas/`:`get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。

View File

@@ -2,9 +2,83 @@
"schemaVersion": 1, "schemaVersion": 1,
"skill": "account-manager", "skill": "account-manager",
"actions": [ "actions": [
{
"id": "account-add-web",
"label": "新增账号",
"description": "登记网页/RPA 账号并自动创建浏览器 Profile 目录(走 account add-web,非宿主直写库)。",
"replaces": "create",
"placements": [],
"executionProfile": "sync",
"interaction": "form",
"bind": {
"tables": ["accounts"]
},
"inputSchema": {
"type": "object",
"required": ["platform_key", "account_label"],
"properties": {
"platform_key": {
"type": "string",
"title": "平台",
"description": "关联平台信息表;宿主按列 lookup 渲染下拉"
},
"account_label": {
"type": "string",
"title": "账号名称"
},
"login_id": {
"type": "string",
"title": "登录账号"
},
"login_id_type": {
"type": "string",
"title": "登录标识类型",
"default": "unknown"
},
"tenant_id": {
"type": "string",
"title": "租户编号"
},
"environment": {
"type": "string",
"title": "环境",
"default": "production"
},
"role": {
"type": "string",
"title": "角色",
"default": "default"
},
"provider_code": {
"type": "string",
"title": "供应商代码"
},
"url": {
"type": "string",
"title": "入口链接"
}
}
},
"entrypoint": {
"type": "cli",
"command": "account",
"args": [
"add-web",
"--platform", "{{platform_key}}",
"--label", "{{account_label}}",
"--login-id", "{{login_id}}",
"--login-id-type", "{{login_id_type}}",
"--tenant-id", "{{tenant_id}}",
"--environment", "{{environment}}",
"--role", "{{role}}",
"--provider-code", "{{provider_code}}",
"--url", "{{url}}"
]
}
},
{ {
"id": "reveal-profile", "id": "reveal-profile",
"label": "打开文件位置", "label": "打开目录",
"description": "在系统文件管理器中打开该账号的浏览器 Profile 目录。", "description": "在系统文件管理器中打开该账号的浏览器 Profile 目录。",
"placements": ["row"], "placements": ["row"],
"executionProfile": "sync", "executionProfile": "sync",
@@ -34,7 +108,7 @@
{ {
"id": "open-browser", "id": "open-browser",
"label": "打开浏览器", "label": "打开浏览器",
"description": "用该账号持久化 Profile 打开 Chrome/Edge,仅供肉眼核对(不做登录判定)。一点即开,不等待关闭。", "description": "用该账号持久化 Profile 直接打开系统 Chrome/Edge,仅供肉眼核对(不做登录判定)。与打开目录同为一点即开。",
"placements": ["row"], "placements": ["row"],
"executionProfile": "sync", "executionProfile": "sync",
"interaction": "direct", "interaction": "direct",

View File

@@ -54,7 +54,7 @@
| `account mark-session <id> --session-status <status>` | 标记会话状态 | | `account mark-session <id> --session-status <status>` | 标记会话状态 |
| `account mark-error <id> --code <code> --message <msg>` | 标记错误 | | `account mark-error <id> --code <code> --message <msg>` | 标记错误 |
| `account mark-used <id>` | 标记已使用 | | `account mark-used <id>` | 标记已使用 |
| `account open <id>` | 打开持久化浏览器 profile(仅查看;**拉起后立即返回**,不等待关闭) | | `account open <id>` | 本进程直接打开系统 Chrome/Edge(独立 profile;最大化;跳过首次欢迎页;打开平台 URL) |
| `account reveal-profile <id>` | 在系统文件管理器中打开该账号 `profile_dir` | | `account reveal-profile <id>` | 在系统文件管理器中打开该账号 `profile_dir` |
### D. 加密备份与迁移(v2) ### D. 加密备份与迁移(v2)
@@ -75,8 +75,8 @@
| `lease release <lease_token>` | 释放租约 | | `lease release <lease_token>` | 释放租约 |
| `lease list [--active]` | 列出租约 | | `lease list [--active]` | 列出租约 |
| `lease cleanup` | 清理过期租约 | | `lease cleanup` | 清理过期租约 |
| `account open <id>` / `open <id>` | 浏览器查看 | | `account open <id>` / `open <id>` | 打开浏览器 |
| `account reveal-profile <id>` | 打开 Profile 目录 | | `account reveal-profile <id>` | 打开目录 |
### 兼容入口(旧版别名) ### 兼容入口(旧版别名)

View File

@@ -6,6 +6,7 @@ SQLite 无原生表/字段 COMMENT;中文展示名称通过这两张元数据
from __future__ import annotations from __future__ import annotations
import json
from typing import Iterable from typing import Iterable
INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"}) INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"})
@@ -20,6 +21,26 @@ ACCOUNT_LEASES_TABLE = "account_leases"
DATETIME_UNIX_SECONDS = "datetime_unix_seconds" DATETIME_UNIX_SECONDS = "datetime_unix_seconds"
STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"}) STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"})
# Data-management lookup: host resolves options_json object into select options.
PLATFORM_KEY_LOOKUP_JSON = json.dumps(
{
"ref_table": "platforms",
"value_column": "platform_key",
"label_column": "display_name",
"filter_column": "enabled",
"filter_value": 1,
},
ensure_ascii=False,
)
ACCOUNT_ID_LOOKUP_JSON = json.dumps(
{
"ref_table": "accounts",
"value_column": "id",
"label_column": "account_label",
},
ensure_ascii=False,
)
USER_BUSINESS_TABLES = ( USER_BUSINESS_TABLES = (
PLATFORMS_TABLE, PLATFORMS_TABLE,
ACCOUNTS_TABLE, ACCOUNTS_TABLE,
@@ -123,7 +144,13 @@ ACCOUNTS_TABLE_METADATA = {
ACCOUNTS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( ACCOUNTS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
_col("id", "编号", description="账号唯一编号", editable=0), _col("id", "编号", description="账号唯一编号", editable=0),
_col("platform_key", "平台", description="所属平台键"), _col(
"platform_key",
"平台",
description="所属平台键(来自平台信息表)",
display_type="lookup",
options_json=PLATFORM_KEY_LOOKUP_JSON,
),
_col("account_label", "账号名称", description="人可读的账号名称"), _col("account_label", "账号名称", description="人可读的账号名称"),
_col("login_id", "登录账号", description="手机号、邮箱、用户名等登录标识"), _col("login_id", "登录账号", description="手机号、邮箱、用户名等登录标识"),
_col("login_id_type", "登录标识类型", description="phone / email / username / unknown 等"), _col("login_id_type", "登录标识类型", description="phone / email / username / unknown 等"),
@@ -158,8 +185,20 @@ CREDENTIALS_TABLE_METADATA = {
CREDENTIALS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( CREDENTIALS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
_col("id", "编号", description="凭据唯一编号", editable=0), _col("id", "编号", description="凭据唯一编号", editable=0),
_col("account_id", "账号编号", description="关联的 accounts.id,可空"), _col(
_col("platform_key", "平台", description="所属平台键"), "account_id",
"账号编号",
description="关联的 accounts.id,可空",
display_type="lookup",
options_json=ACCOUNT_ID_LOOKUP_JSON,
),
_col(
"platform_key",
"平台",
description="所属平台键(来自平台信息表)",
display_type="lookup",
options_json=PLATFORM_KEY_LOOKUP_JSON,
),
_col("credential_label", "凭据名称", description="人可读的凭据名称"), _col("credential_label", "凭据名称", description="人可读的凭据名称"),
_col("credential_type", "凭据类型", description="api_key / password / bearer_token 等"), _col("credential_type", "凭据类型", description="api_key / password / bearer_token 等"),
_col("secret_storage", "密钥存储方式", description="none / env / windows_credential / local_encrypted", editable=0), _col("secret_storage", "密钥存储方式", description="none / env / windows_credential / local_encrypted", editable=0),

View File

@@ -162,7 +162,9 @@ def create_web_account_record(
finally: finally:
conn.close() conn.close()
shortcut_path = create_profile_browser_shortcut(resolved_profile_dir, safe_label, log) shortcut_path = create_profile_browser_shortcut(
resolved_profile_dir, safe_label, log, url=resolved_url,
)
log.info("account_add_web_success id=%s platform=%s profile_dir=%s", new_id, key, resolved_profile_dir) log.info("account_add_web_success id=%s platform=%s profile_dir=%s", new_id, key, resolved_profile_dir)
ok_payload = { ok_payload = {

View File

@@ -2,7 +2,7 @@
""" """
Chromium/Edge browser profile opener. Chromium/Edge browser profile opener.
Uses system Chrome/Edge channel, Playwright launch_persistent_context. Uses system Chrome/Edge executable with --user-data-dir (same profile as RPA shortcuts).
Does NOT download Playwright Chromium. Does NOT download Playwright Chromium.
Does NOT check login status. Does NOT check login status.
Does NOT write secret to SQLite. Does NOT write secret to SQLite.
@@ -11,20 +11,12 @@ import json
import os import os
import subprocess import subprocess
import sys import sys
import tempfile
from db.connection import get_conn, init_db from db.connection import get_conn, init_db
from db.accounts_repo import get_account_by_id, get_platform_from_db from db.accounts_repo import get_account_by_id, get_platform_from_db
from util.logging_config import ( from util.logging_config import get_skill_logger
get_skill_logger,
subprocess_env_with_trace,
)
from util.platforms import PLATFORMS from util.platforms import PLATFORMS
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import)
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
_SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR)
def _win_find_exe(candidates): def _win_find_exe(candidates):
for p in candidates: for p in candidates:
@@ -83,6 +75,35 @@ def resolve_chromium_channel():
return "chrome" return "chrome"
# 给人核对/手工登录用:跳过 Chrome 首次欢迎页并最大化。不要带 Playwright 的 --enable-automation。
HUMAN_CHROME_FLAGS = (
"--start-maximized",
"--no-first-run",
"--no-default-browser-check",
"--disable-search-engine-choice-screen",
)
def human_chrome_launch_args(profile_dir, url=None):
"""chrome.exe 之后的参数(Popen 列表形式,路径不额外加引号)。"""
profile_abs = os.path.abspath(profile_dir)
args = [f"--user-data-dir={profile_abs}", *HUMAN_CHROME_FLAGS]
cleaned = (url or "").strip()
if cleaned:
args.append(cleaned)
return args
def human_chrome_shortcut_arguments(profile_dir, url=None):
"""Windows .lnk Arguments 字符串(user-data-dir 带引号)。"""
profile_abs = os.path.abspath(profile_dir)
parts = [f'--user-data-dir="{profile_abs}"', *HUMAN_CHROME_FLAGS]
cleaned = (url or "").strip()
if cleaned:
parts.append(cleaned)
return " ".join(parts)
def _print_browser_install_hint(): def _print_browser_install_hint():
print("❌ 未检测到 Google Chrome 或 Microsoft Edge,请先安装:") print("❌ 未检测到 Google Chrome 或 Microsoft Edge,请先安装:")
print(" • Chrome: https://www.google.com/chrome/") print(" • Chrome: https://www.google.com/chrome/")
@@ -155,6 +176,10 @@ def cmd_reveal_profile(account_id):
print( print(
json.dumps( json.dumps(
{ {
"type": "result",
"schema_version": 1,
"status": "success",
"message": "",
"success": True, "success": True,
"data": { "data": {
"account_id": int(target["id"]) if target.get("id") is not None else account_id, "account_id": int(target["id"]) if target.get("id") is not None else account_id,
@@ -166,33 +191,11 @@ def cmd_reveal_profile(account_id):
) )
def _spawn_browser_child(cmd: list[str], *, cwd: str, env: dict) -> subprocess.Popen:
"""Detach Playwright child so `account open` can return immediately."""
popen_kwargs: dict = {
"cwd": cwd,
"env": env,
"stdin": subprocess.DEVNULL,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"close_fds": True,
}
if sys.platform == "win32":
# Keep browser window; detach from parent console / Skill Action wait.
flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0)
if create_no_window:
flags |= create_no_window
popen_kwargs["creationflags"] = flags
else:
popen_kwargs["start_new_session"] = True
return subprocess.Popen(cmd, **popen_kwargs)
def cmd_open(account_id): def cmd_open(account_id):
""" """
打开该账号的持久化浏览器,仅用于肉眼核对。 打开该账号的持久化浏览器,仅用于肉眼核对 / 手工登录 / 抓 DOM。
不写数据库、不判定是否已登录。 与 reveal-profile 同模式:本进程直接拉起系统 Chrome/Edge 后立即返回。
拉起浏览器子进程后立即返回(不等待窗口关闭),便于 Skill Action sync/direct。 最大化、跳过 Chrome 首次欢迎页;不写数据库、不判定是否已登录、不起 Playwright。
""" """
log = get_skill_logger() log = get_skill_logger()
init_db() init_db()
@@ -204,23 +207,23 @@ def cmd_open(account_id):
print("ERROR:ACCOUNT_NOT_FOUND") print("ERROR:ACCOUNT_NOT_FOUND")
sys.exit(1) sys.exit(1)
channel = resolve_chromium_channel() browser_exe = resolve_chromium_executable()
if not channel: if not browser_exe:
_print_browser_install_hint() channel = resolve_chromium_channel()
sys.exit(1) if sys.platform == "win32" or not channel:
_print_browser_install_hint()
try: sys.exit(1)
import playwright # noqa: F401 # 非 Windows:退回 PATH 上的常见命令名
except ImportError: browser_exe = "google-chrome" if channel == "chrome" else "microsoft-edge"
print("ERROR:需要 playwright Python 包。")
sys.exit(1)
profile_dir = (target.get("profile_dir") or "").strip() profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir: if not profile_dir:
log.warning("browser_open_error account_id=%s profile_dir_missing", account_id) log.warning("browser_open_error account_id=%s profile_dir_missing", account_id)
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。") print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
sys.exit(1) sys.exit(1)
os.makedirs(profile_dir, exist_ok=True)
profile_abs = os.path.abspath(profile_dir)
os.makedirs(profile_abs, exist_ok=True)
conn = get_conn() conn = get_conn()
try: try:
@@ -228,55 +231,63 @@ def cmd_open(account_id):
finally: finally:
conn.close() conn.close()
# 与 profile_shortcut 同一套人用参数:独立 user-data-dir 保存登录态
cmd = [browser_exe, *human_chrome_launch_args(profile_abs, url)]
log.info( log.info(
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s", "browser_open_launch exe=%s profile_dir=%s url=%s platform=%s",
profile_dir, browser_exe,
profile_abs,
url, url,
channel,
target.get("platform_key"), target.get("platform_key"),
) )
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8"
) as jf:
json.dump(cfg, jf, ensure_ascii=False)
cfg_path = jf.name
try: try:
child = _spawn_browser_child( # GUI 浏览器:不挂控制台黑框;不等待关闭(与 reveal 一致立刻返回)
[sys.executable, "-m", "service.open_child_runner", cfg_path], popen_kwargs: dict = {
cwd=_SCRIPTS_ROOT, "stdin": subprocess.DEVNULL,
env=subprocess_env_with_trace(), "stdout": subprocess.DEVNULL,
) "stderr": subprocess.DEVNULL,
except Exception as e: "close_fds": True,
try: }
os.unlink(cfg_path) if os.path.isabs(browser_exe):
except OSError: popen_kwargs["cwd"] = os.path.dirname(browser_exe)
pass if sys.platform == "win32":
log.error("browser_open_error account_id=%s error=%s", account_id, str(e)) popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
print(f"ERROR:BROWSER_OPEN_FAILED {e}") else:
popen_kwargs["start_new_session"] = True
child = subprocess.Popen(cmd, **popen_kwargs)
except OSError as exc:
log.error("browser_open_error account_id=%s error=%s", account_id, str(exc))
print(f"ERROR:BROWSER_OPEN_FAILED {exc}")
sys.exit(1) sys.exit(1)
browser_name = (
"Microsoft Edge"
if os.path.basename(browser_exe).lower() == "msedge.exe"
else "Google Chrome"
)
log.info( log.info(
"browser_open_spawned account_id=%s channel=%s pid=%s", "browser_open_ok account_id=%s browser=%s pid=%s",
account_id, account_id,
channel, browser_name,
child.pid, child.pid,
) )
print( print(
json.dumps( json.dumps(
{ {
"type": "result",
"schema_version": 1,
"status": "success",
"message": "",
"success": True, "success": True,
"data": { "data": {
"account_id": int(target["id"]) if target.get("id") is not None else account_id, "account_id": int(target["id"]) if target.get("id") is not None else account_id,
"account_label": target.get("account_label"), "account_label": target.get("account_label"),
"browser": browser_name, "browser": browser_name,
"channel": channel, "browser_exe": browser_exe,
"profile_dir": os.path.abspath(profile_dir), "profile_dir": profile_abs,
"url": url, "url": url,
"detached": True,
"pid": child.pid, "pid": child.pid,
}, },
}, },

View File

@@ -1,56 +0,0 @@
"""Playwright 仅打开浏览器子进程:由 browser_service.cmd_open 启动,argv[1] 为 JSON 配置路径。
父进程拉起后即返回;本子进程自行维护浏览器生命周期,读完配置后删除临时 JSON。
"""
import json
import os
import sys
from playwright.sync_api import sync_playwright
from util.playwright_stealth import (
STEALTH_INIT_SCRIPT,
persistent_context_launch_parts,
stealth_enabled,
)
def main():
cfg_path = sys.argv[1]
with open(cfg_path, encoding="utf-8") as f:
c = json.load(f)
try:
os.unlink(cfg_path)
except OSError:
pass
with sync_playwright() as p:
args, ignore_automation = persistent_context_launch_parts()
lc_kw = dict(
user_data_dir=c["profile_dir"],
headless=False,
channel=c["channel"],
no_viewport=True,
args=args,
)
if ignore_automation is not None:
lc_kw["ignore_default_args"] = ignore_automation
ctx = p.chromium.launch_persistent_context(**lc_kw)
if stealth_enabled():
ctx.add_init_script(STEALTH_INIT_SCRIPT)
try:
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(c["url"], wait_until="domcontentloaded", timeout=60000)
ctx.wait_for_event("close", timeout=86400000)
except Exception as e:
print(e, file=sys.stderr)
raise
finally:
try:
ctx.close()
except Exception:
pass
if __name__ == "__main__":
main()

View File

@@ -9,7 +9,10 @@ import subprocess
import sys import sys
from typing import Optional from typing import Optional
from service.browser_service import resolve_chromium_executable from service.browser_service import (
human_chrome_shortcut_arguments,
resolve_chromium_executable,
)
_WIN_LNK_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]') _WIN_LNK_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
@@ -28,9 +31,10 @@ def create_profile_browser_shortcut(
profile_dir: str, profile_dir: str,
account_label: str, account_label: str,
log: Optional[logging.Logger] = None, log: Optional[logging.Logger] = None,
url: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
""" """
在 profile_dir 内创建 {account_label}.lnk,目标为系统 Chrome/Edge + --user-data-dir。 在 profile_dir 内创建 {account_label}.lnk,与「打开浏览器」同一套人用参数。
非 Windows 或未安装 Chrome/Edge 时静默跳过,返回 None。 非 Windows 或未安装 Chrome/Edge 时静默跳过,返回 None。
成功返回 .lnk 绝对路径。 成功返回 .lnk 绝对路径。
@@ -52,7 +56,7 @@ def create_profile_browser_shortcut(
lnk_name = f"{_lnk_safe_filename(account_label)}.lnk" lnk_name = f"{_lnk_safe_filename(account_label)}.lnk"
lnk_path = os.path.join(profile_abs, lnk_name) lnk_path = os.path.join(profile_abs, lnk_name)
arguments = f'--user-data-dir="{profile_abs}"' arguments = human_chrome_shortcut_arguments(profile_abs, url)
working_dir = os.path.dirname(browser_exe) working_dir = os.path.dirname(browser_exe)
ps = ( ps = (

View File

@@ -2,6 +2,9 @@
""" """
Browser launch helper checks (no real browser started). Browser launch helper checks (no real browser started).
""" """
import json
from unittest import mock
import pytest import pytest
@@ -14,28 +17,11 @@ class TestPlaywrightLaunchHelpers:
args, _ignore = persistent_context_launch_parts() args, _ignore = persistent_context_launch_parts()
assert "--start-maximized" in args assert "--start-maximized" in args
def test_open_child_runner_cleans_config_after_read(self):
import os
path = os.path.join( class TestCmdOpenDirectBrowser:
os.path.dirname(__file__), """account open = same-process Chrome/Edge launch (like reveal-profile)."""
"..",
"scripts",
"service",
"open_child_runner.py",
)
path = os.path.abspath(path)
with open(path, encoding="utf-8") as f:
src = f.read()
assert "os.unlink(cfg_path)" in src
assert "wait_for_event" in src
assert "launch_persistent_context" in src
assert "headless=False" in src
assert "no_viewport=True" in src
def test_cmd_open_launches_browser_exe(self, clean_db, tmp_path, capsys, monkeypatch):
class TestCmdOpenDetached:
def test_cmd_open_spawns_and_returns(self, clean_db, tmp_path, capsys, monkeypatch):
from db.accounts_repo import insert_account from db.accounts_repo import insert_account
from service import browser_service from service import browser_service
from util.platforms import seed_platforms from util.platforms import seed_platforms
@@ -60,30 +46,71 @@ class TestCmdOpenDetached:
) )
clean_db.commit() clean_db.commit()
monkeypatch.setattr(browser_service, "resolve_chromium_channel", lambda: "chrome") fake_exe = str(tmp_path / "chrome.exe")
monkeypatch.setattr(
browser_service,
"resolve_chromium_executable",
lambda: fake_exe,
)
class FakeProc: class FakeProc:
pid = 4242 pid = 5252
spawned = {} spawned = {}
def fake_spawn(cmd, *, cwd, env): def fake_popen(cmd, **kwargs):
spawned["cmd"] = cmd spawned["cmd"] = list(cmd)
spawned["cwd"] = cwd spawned["kwargs"] = kwargs
return FakeProc() return FakeProc()
monkeypatch.setattr(browser_service, "_spawn_browser_child", fake_spawn) monkeypatch.setattr(browser_service.subprocess, "Popen", fake_popen)
browser_service.cmd_open(str(aid)) browser_service.cmd_open(str(aid))
out = capsys.readouterr().out.strip() out = capsys.readouterr().out.strip()
import json
payload = json.loads(out.splitlines()[-1]) payload = json.loads(out.splitlines()[-1])
assert payload["success"] is True assert payload["success"] is True
assert payload["data"]["detached"] is True assert payload["message"] == ""
assert payload["data"]["pid"] == 4242 assert payload["type"] == "result"
assert "-m" in spawned["cmd"] assert payload["status"] == "success"
assert "service.open_child_runner" in spawned["cmd"] assert payload["data"]["pid"] == 5252
assert payload["data"]["browser_exe"] == fake_exe
assert spawned["cmd"][0] == fake_exe
assert spawned["cmd"][1].startswith("--user-data-dir=")
assert "--start-maximized" in spawned["cmd"]
assert "--no-first-run" in spawned["cmd"]
assert "--no-default-browser-check" in spawned["cmd"]
assert "--disable-search-engine-choice-screen" in spawned["cmd"]
assert spawned["cmd"][-1] == "https://example.com"
assert "--enable-automation" not in spawned["cmd"]
# must NOT spawn python / open_child_runner
joined = " ".join(spawned["cmd"])
assert "open_child_runner" not in joined
assert "python" not in joined.lower()
class TestHumanChromeLaunchArgs:
def test_flags_then_url(self, tmp_path):
from service.browser_service import HUMAN_CHROME_FLAGS, human_chrome_launch_args
profile = str(tmp_path / "prof")
args = human_chrome_launch_args(profile, "https://chat.deepseek.com")
assert args[0].startswith("--user-data-dir=")
for flag in HUMAN_CHROME_FLAGS:
assert flag in args
assert args[-1] == "https://chat.deepseek.com"
assert "--enable-automation" not in args
def test_shortcut_arguments_quote_profile_and_include_url(self, tmp_path):
from service.browser_service import human_chrome_shortcut_arguments
profile = tmp_path / "my prof"
profile.mkdir()
text = human_chrome_shortcut_arguments(str(profile), "https://example.com")
assert '--user-data-dir="' in text
assert "--start-maximized" in text
assert "--no-first-run" in text
assert text.endswith("https://example.com")
class TestResolveChromiumChannel: class TestResolveChromiumChannel:

View File

@@ -102,6 +102,44 @@ def test_sensitive_credential_fields_not_editable(metadata_db):
assert int(searchable) == 0, column_name assert int(searchable) == 0, column_name
def test_account_and_credential_platform_lookups(metadata_db):
import json
cur = metadata_db.cursor()
for table_name, column_name in (
(ACCOUNTS_TABLE, "platform_key"),
(CREDENTIALS_TABLE, "platform_key"),
):
cur.execute(
"""
SELECT display_type, options_json
FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(table_name, column_name),
)
display_type, options_json = cur.fetchone()
assert display_type == "lookup", table_name
payload = json.loads(options_json)
assert payload["ref_table"] == "platforms"
assert payload["value_column"] == "platform_key"
assert payload["label_column"] == "display_name"
cur.execute(
"""
SELECT display_type, options_json
FROM _jiangchang_columns
WHERE table_name = ? AND column_name = 'account_id'
""",
(CREDENTIALS_TABLE,),
)
display_type, options_json = cur.fetchone()
assert display_type == "lookup"
payload = json.loads(options_json)
assert payload["ref_table"] == "accounts"
assert payload["value_column"] == "id"
def test_lease_token_not_editable(metadata_db): def test_lease_token_not_editable(metadata_db):
cur = metadata_db.cursor() cur = metadata_db.cursor()
cur.execute( cur.execute(

View File

@@ -31,6 +31,33 @@ class TestCreateProfileBrowserShortcut:
out = create_profile_browser_shortcut(str(tmp_path), "demo") out = create_profile_browser_shortcut(str(tmp_path), "demo")
assert out is None assert out is None
def test_windows_lnk_uses_human_chrome_args(self, tmp_path, monkeypatch) -> None:
captured = {}
def fake_run(argv, **kwargs):
captured["argv"] = list(argv)
class Result:
returncode = 0
return Result()
monkeypatch.setattr("util.profile_shortcut.sys.platform", "win32")
monkeypatch.setattr(
"util.profile_shortcut.resolve_chromium_executable",
lambda: r"C:\Program Files\Google\Chrome\Application\chrome.exe",
)
monkeypatch.setattr("util.profile_shortcut.subprocess.run", fake_run)
monkeypatch.setattr("util.profile_shortcut.os.path.isfile", lambda path: True)
out = create_profile_browser_shortcut(
str(tmp_path), "demo", url="https://chat.deepseek.com",
)
assert out is not None
ps = captured["argv"][-1]
assert "--start-maximized" in ps
assert "--no-first-run" in ps
assert "https://chat.deepseek.com" in ps
assert "--enable-automation" not in ps
@pytest.mark.skipif(sys.platform != "win32", reason="Windows .lnk only") @pytest.mark.skipif(sys.platform != "win32", reason="Windows .lnk only")
def test_creates_lnk_on_windows(self, tmp_path, monkeypatch) -> None: def test_creates_lnk_on_windows(self, tmp_path, monkeypatch) -> None:
from service.browser_service import resolve_chromium_executable from service.browser_service import resolve_chromium_executable

View File

@@ -54,6 +54,9 @@ def test_reveal_profile_opens_dir(clean_db, tmp_path, capsys):
out = capsys.readouterr().out.strip() out = capsys.readouterr().out.strip()
payload = json.loads(out.splitlines()[-1]) payload = json.loads(out.splitlines()[-1])
assert payload["success"] is True assert payload["success"] is True
assert payload["message"] == ""
assert payload["type"] == "result"
assert payload["status"] == "success"
assert payload["data"]["account_id"] == aid assert payload["data"]["account_id"] == aid
assert payload["data"]["profile_dir"] == called_path assert payload["data"]["profile_dir"] == called_path

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
"""数据管理行内打开类:label 对齐模板,不与宿主 CRUD 重复。"""
from __future__ import annotations
import json
import os
import unittest
def _skill_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _load_actions() -> list[dict]:
path = os.path.join(_skill_root(), "assets", "actions.json")
with open(path, encoding="utf-8") as f:
manifest = json.load(f)
return list(manifest.get("actions") or [])
def _by_id(actions: list[dict], action_id: str) -> dict:
for item in actions:
if item.get("id") == action_id:
return item
raise AssertionError(f"missing action {action_id}")
class TestRowOpenActions(unittest.TestCase):
def test_reveal_and_open_use_template_labels(self) -> None:
actions = _load_actions()
reveal = _by_id(actions, "reveal-profile")
open_browser = _by_id(actions, "open-browser")
self.assertEqual(reveal.get("label"), "打开目录")
self.assertEqual(open_browser.get("label"), "打开浏览器")
self.assertEqual(reveal.get("placements"), ["row"])
self.assertEqual(open_browser.get("placements"), ["row"])
self.assertEqual(reveal.get("interaction"), "direct")
self.assertEqual(open_browser.get("interaction"), "direct")
self.assertFalse(reveal.get("replaces"))
self.assertFalse(open_browser.get("replaces"))
def test_create_takes_over_host_add_without_row_duplicate(self) -> None:
create = _by_id(_load_actions(), "account-add-web")
self.assertEqual(create.get("replaces"), "create")
self.assertEqual(create.get("placements") or [], [])
self.assertEqual(create.get("label"), "新增账号")
if __name__ == "__main__":
unittest.main()