diff --git a/.agents/skills/hapsign-signing/SKILL.md b/.agents/skills/hapsign-signing/SKILL.md index d34e804..9da3f75 100644 --- a/.agents/skills/hapsign-signing/SKILL.md +++ b/.agents/skills/hapsign-signing/SKILL.md @@ -88,15 +88,27 @@ Interpret doctor capabilities separately: ```text sign --hap \ - --output --browser system_controlled --json + --output --browser auto --events json --json ``` - `system_controlled` uses an isolated Edge/Chrome context without the user's - cookies or saved passwords and grants the authorization page local callback - access. Use `system` only when the user explicitly prefers their normal - browser profile or controlled launch is unavailable; disclose that cached - SSO state, extensions, and local-network permissions can change the flow. - Use `playwright` only when bundled Chromium is explicitly preferred. + `auto` uses an isolated Edge/Chrome context in a visible desktop session. + In SSH, CI, or a headless Linux session it emits an `auth_required` event + instead of launching an invisible browser. `system_controlled` forces the isolated + browser, `system` uses the user's normal browser profile, and `playwright` + explicitly prefers bundled Chromium. + + A first-time login or reauthentication after cache rejection does not support + a completely browserless, terminal-only flow. An external flow removes the + desktop requirement from the CLI host, but the user still needs a modern + browser on some computer and a secure same-port loopback forwarding path. + A cached token that the service still accepts can be reused without opening a + browser for that invocation. + + Real user-visible source CLI login has been verified on Windows 11 Pro 64-bit + with isolated Edge, and Ubuntu 24.04.4 LTS x86_64 under WSL2 with the Windows + default browser. This covers the login page, loopback callback, token exchange, + and platform cache reuse. Do not extend that claim to native Linux or remote + SSH, macOS, GUI, USB, signing, or installation without separate validation. If persistent locations matter, pass absolute `--state-dir` and either an exact `--output` or `--output-dir`. CLI flags override @@ -111,9 +123,23 @@ Interpret doctor capabilities separately: requires it. 4. If authorization opens, tell the user to complete login, CAPTCHA, consent, - and two-factor verification manually, then wait for the process. Never - request, read, store, type, or automate credentials, CAPTCHA, one-time codes, - consent clicks, or tokens. + and two-factor verification manually, then wait for the process. If stderr + emits `HAPSIGN_EVENT=` with `event: auth_required` and `method` set to + `ssh_loopback` or `loopback_forwarding`, keep the process running and give + the user the exact same-port SSH forwarding command described by + `callback_port` when the host is reachable by SSH: + + ```text + ssh -N -L 127.0.0.1::127.0.0.1: + ``` + + For a container or another remote runtime, use its equivalent private port + forwarding instead of exposing the callback publicly. Tell the user to open + `verification_uri` only on the computer that owns the forwarded loopback + port. Treat that URI as an ephemeral secret: do not put + it in persistent logs or share it beyond the authorizing user. Never expose + the callback on `0.0.0.0`, and never request, read, store, type, or automate + credentials, CAPTCHA, one-time codes, consent clicks, or tokens. 5. Treat a nonzero exit code or JSON `ok: false` as failure. On success, read `signed_hap` from JSON instead of guessing a path, then verify it: @@ -142,6 +168,9 @@ Keep logs at a nonsensitive level and use stage markers: - No `[callback]` entry: the browser did not reach the loopback callback; check browser mode and local-network access. +- `auth_required` with `method: ssh_loopback` or `loopback_forwarding`: verify + the local and remote forwarded ports are identical and the browser is on the + forwarding computer. - Callback POST/GET without `授权回调校验成功`: inspect the redacted CSRF or parameter error. - Callback validation succeeded: browser spinning or subsequent diff --git a/CHANGELOG.md b/CHANGELOG.md index c9087dc..5b41534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,9 @@ 无效时会执行一次性材料刷新;能力模式可通过一致使用 `--enable-capability` 匹配, 只有其余一致性校验通过时,才可显式运行 `migrate-cache --profile-type normal|system-basic` 保留缓存。 -- `HAPSIGN-BREAKING-002`:CLI 默认浏览器从普通系统 Profile 改为隔离的 - `system_controlled`;可用 `--browser` 或 `HAPSIGN_BROWSER` 配置。 +- `HAPSIGN-BREAKING-002`:CLI 默认浏览器从普通系统 Profile 改为自适应 `auto`; + 桌面会话仍优先使用隔离的受控浏览器,SSH、CI 或无桌面 Linux 会话改为外部浏览器交接。 + 可用 `--browser` 或 `HAPSIGN_BROWSER` 配置。 - `HAPSIGN-BREAKING-003`:CLI 默认状态/产物从 PR #5 的 `~/.hapsign`(更早版本为 进程工作目录)改到应用目录;可用 `--state-dir`、`--output-dir`、 `HAPSIGN_SIGNING_DIR` 和 `HAPSIGN_SIGNED_HAPS_DIR` 恢复原路径;`inspect` 会检测 @@ -22,6 +23,9 @@ ### Added +- CLI 新增 `auto`/`external` 浏览器模式、可配置 loopback 回调端口和认证超时;SSH、 + CI 或无桌面 Linux 会话会输出安全端口转发指引,`--events json` 提供结构化中间事件且不改变 + 最终单行 JSON stdout 协议。 - 新增面向 Agent 的 `auth`、`devices list`、`sign`、`install`、`deploy` CLI 子命令;支持单行 JSON stdout、stderr 日志、明确退出码与输入校验。 - CLI 支持显式 HDC `--serial`、真机/模拟器候选标记、签名与安装分离,以及安装后 diff --git a/PORTABLE.md b/PORTABLE.md index a0438e4..d0b7626 100644 --- a/PORTABLE.md +++ b/PORTABLE.md @@ -17,9 +17,25 @@ ```bash ./hapsign-cli doctor --json ./hapsign-cli sign --hap app.hap --output app-signed.hap \ - --browser system_controlled --json + --browser auto --json ``` +CLI 的 `auto` 模式在 SSH、CI 或无桌面 Linux 会话中会显示一次性登录地址和 callback +端口。保持命令运行,在有浏览器的电脑按提示建立同端口 SSH loopback 转发后打开该 +地址;容器等运行时使用其等价的私有端口转发能力。 +`--events json` 可输出带 `HAPSIGN_EVENT=` 前缀的结构化交接事件;最终 stdout JSON +协议不变。首次认证仍需要现代浏览器,CLI 不会收集账号密码或验证码。 + +> [!IMPORTANT] +> 首次认证以及缓存失效后的重新认证,不支持完全无浏览器的纯命令行闭环。无桌面 +> Linux 可以运行 CLI,但必须能使用另一台有现代浏览器的电脑,并将同一 loopback +> 端口安全转发回 CLI。已有且仍有效的 Token 缓存可直接复用,无需每次打开浏览器。 + +本登录实现已在 Windows 11 专业版 64 位,以及 Ubuntu 24.04.4 LTS x86_64(WSL2) +完成源码 CLI 真实登录验证。验证覆盖用户可见登录页、loopback 回调、Token 换取和 +平台缓存复用;不包含原生 Linux/远程 SSH、macOS、GUI、USB 或完整签名安装链路, +这些场景仍需分别回归。 + Windows PowerShell 使用 `.\hapsign-cli.exe`。完整约定见 `AGENT_SIGNING.md`;升级 已有安装前请查看 `MIGRATIONS.md`,或读取 `doctor --json` 的机器可读变更目录。 diff --git a/README.md b/README.md index d122cea..94a24f2 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,8 @@ python -m playwright install --no-shell chromium 桌面设置提供“受控系统浏览器”“内置 Chromium”和“非受控系统默认浏览器”三种 模式;环境变量 `HAPSIGN_BROWSER` 可使用 `system_controlled`、`playwright` -或 `system` 覆盖代码默认值。 +或 `system` 覆盖桌面默认值。CLI 默认使用 `auto`:SSH、CI 或无桌面 Linux 会话不会 +尝试启动不可见的浏览器,而会显示外部浏览器交接信息;也可显式使用 `external`。 ### 可选:配置 DevEco Studio 回退路径 @@ -235,15 +236,28 @@ CLI 的标准输出可以保持为单个 JSON 文档,普通运行日志写入 hapsign doctor --json hapsign inspect --hap app.hap --json hapsign sign --hap app.hap --output artifacts/app-signed.hap \ - --state-dir .hapsign-state --browser system_controlled --json + --state-dir .hapsign-state --browser auto --json ``` +在 SSH、CI 或无桌面 Linux 上,`auto` 会保持命令运行并输出一次性登录地址与回调端口。 +在有浏览器的电脑另开终端,把提示中的同一端口转发到运行 HapSign 的 SSH 目标,再打开 +地址: + +```bash +ssh -N -L 127.0.0.1:<端口>:127.0.0.1:<端口> <同一SSH目标> +``` + +容器等环境应使用对应的私有端口转发能力。回调服务始终只监听 loopback,不应改为公网 +监听。`--events json` 会在 stderr 输出带 +`HAPSIGN_EVENT=` 前缀的结构化交接事件;最终 stdout 仍只有原有单行 JSON。登录地址 +包含一次性状态值,不要分享或写入持久日志。 + 首次为某个应用申请 Profile 时仍需要设备 UDID。默认会通过 HDC 从已连接设备读取; 如果 agent 已从可信来源获得 UDID,可以跳过本机设备探测: ```bash hapsign sign --hap app.hap --device-udid <64位十六进制UDID> \ - --browser system_controlled --json + --browser auto --json ``` 同一天已有与包名、能力模式及已知设备匹配的可用签名材料缓存时,仅签名模式不要求 @@ -310,7 +324,7 @@ Windows/macOS 输出 `dist/HapSign-portable-.zip`,Linux 输出 hapsign doctor [--state-dir DIR] [--output-dir DIR] [--json] hapsign inspect --hap HAP [--bundle-name NAME] [--state-dir DIR] [--enable-capability] [--json] hapsign migrate-cache --hap HAP --profile-type normal|system-basic [--state-dir DIR] [--json] -hapsign auth [login|status] [--refresh] [--state-dir DIR] [--json] +hapsign auth [login|status] [--refresh] [认证交互选项] [--state-dir DIR] [--json] hapsign devices [list] [--connected-only] [--json] hapsign sign --hap HAP [--serial SERIAL | --device-udid UDID] [签名选项] [--json] hapsign install --hap SIGNED_HAP --serial SERIAL [--bundle-name NAME] [--json] @@ -325,7 +339,10 @@ sign / deploy 签名选项: --output-dir DIR 未指定 --output 时的签名产物目录 --output FILE 签名 HAP 的精确输出路径 --overwrite-output 允许覆盖 --output 指定的已有文件 - --browser MODE system、system_controlled 或 playwright + --browser MODE auto、external、system、system_controlled 或 playwright + --callback-port PORT loopback 回调端口,默认 0 自动分配 + --auth-timeout SEC 浏览器授权等待时间,默认 600 秒 + --events FORMAT 中间事件格式:human 或 json(写入 stderr) --enable-capability 使用 Real Profile(APL=system_basic) --refresh-token 强制浏览器认证,同时刷新签名材料 --refresh-signing 只重新申请证书/Profile,复用有效 Token @@ -354,7 +371,9 @@ sign / deploy 签名选项: ### 首次运行 -会弹出浏览器窗口,打开华为登录页。手动输入账号密码登录,如果有验证码或二次验证也手动处理。登录成功后浏览器会自动关闭,后续自动完成签名和安装。 +有可见桌面时会打开华为登录页;SSH、CI 或无桌面 Linux 会给出外部浏览器和安全 +loopback 转发指引。账号密码、验证码和二次验证始终由用户在浏览器中处理。登录成功后 +继续完成签名和安装。 ### Real Profile(system_basic 权限) @@ -425,9 +444,34 @@ token 缓存是权限限制为 `0o600` 的明文文件,签名私钥等材料 不要上传、分享或放入云同步目录。共享电脑使用完毕后应删除 `signing_files/`。详细 说明见 [SECURITY.md](SECURITY.md)。 -## 限制 +## 已验证平台与规格限制 + +### 已实机验证的登录环境 +下表只描述本次源码 CLI 的真实登录验证范围,不代表 GUI、USB、HAP 签名安装或所有 +同类操作系统版本均已验证。 + +| CLI 运行环境 | 浏览器路径 | 已验证结果 | +| --- | --- | --- | +| Windows 11 专业版 64 位 | `auto` 启动隔离的 Edge | 用户可见登录页、loopback 回调、Token 换取、DPAPI 缓存及缓存复用均通过 | +| Ubuntu 24.04.4 LTS x86_64(WSL2) | `auto` 调用 Windows 默认浏览器 | 用户可见登录页、跨 WSL loopback 回调、Token 换取、`0o600` 缓存及缓存复用均通过 | + +WSL2 结果不能替代原生 Linux 发行版或远程 SSH 环境的实机验证;这些环境应在发布前 +按其网络、浏览器交接和权限配置单独回归。未列入上表的平台不等同于不支持,只表示 +尚未完成同等范围的真实登录验证。 + +### 认证规格边界 + +- 首次认证以及缓存失效后的重新认证,不支持“全程只有纯命令行、任何电脑都没有可用 + 浏览器”的运行方式。OAuth 登录必须由用户在一台能运行现代浏览器的电脑上完成 +- SSH、CI 或无桌面 Linux 可以运行 CLI;`auto`/`external` 会输出一次性登录地址和 + callback 端口,通过安全的同端口 loopback 转发把浏览器回调送回 CLI。因此“不要求 + Linux 主机有桌面”不等于“不需要浏览器” +- 已有且仍被服务端接受的 Token 缓存可以直接复用,此时本次调用无需再次打开浏览器 - 登录验证码 / 二次验证需要用户在浏览器中手动处理 + +### 其他限制 + - 拖拽安装脚本仅支持 Windows(`sign_install.bat`);Linux/macOS 请使用 `hapsign` 命令行 - Windows 便携版已经过完整构建和实机流程验证;Linux x64 已有锁定公开工具链、 diff --git a/docs/AGENT_SIGNING.md b/docs/AGENT_SIGNING.md index 7bc6c0c..a88973c 100644 --- a/docs/AGENT_SIGNING.md +++ b/docs/AGENT_SIGNING.md @@ -5,6 +5,24 @@ 源码安装后的命令名是 `hapsign`;便携版使用 `hapsign-cli.exe`(Windows)或 `./hapsign-cli`(Linux/macOS),其余参数和结果完全相同。 +## 认证规格边界与验证范围 + +首次认证以及缓存失效后的重新认证,不支持完全无浏览器的纯命令行闭环。Linux CLI +可以运行在 SSH、CI 或无桌面环境中,但用户必须能在另一台有现代浏览器的电脑上打开 +一次性登录地址,并通过同端口 loopback 转发把回调安全送回 CLI。“Linux 主机不需要 +桌面”不表示整个认证过程不需要浏览器。已有且仍被服务端接受的 Token 缓存可以直接 +复用,此时本次调用不需要打开浏览器。 + +源码 CLI 的用户可见真实登录已在以下环境验证: + +- Windows 11 专业版 64 位:`auto` 启动隔离 Edge,完成 loopback 回调、Token 换取、 + DPAPI 缓存和缓存复用 +- Ubuntu 24.04.4 LTS x86_64(WSL2):`auto` 调用 Windows 默认浏览器,完成跨 WSL + loopback 回调、Token 换取、`0o600` 缓存和缓存复用 + +该结果不覆盖原生 Linux/远程 SSH、macOS、GUI、USB 或完整签名安装链路;未列入不 +等同于不支持,但发布前应在目标环境执行相应回归。 + ## 稳定调用顺序 ### 1. 诊断运行环境 @@ -58,7 +76,7 @@ Real Profile 使用 `system-basic`。不能从旧元数据可靠推断类型, hapsign sign \ --hap app.hap \ --output artifacts/app-signed.hap \ - --browser system_controlled \ + --browser auto \ --json ``` @@ -67,17 +85,29 @@ hapsign sign \ 拒绝时尝试刷新。agent 应提示用户在浏览器中完成必要的授权,然后继续等待进程退出。 成功结果的 `signed_hap` 是可交付产物的绝对路径。 -Agent 默认应显式使用 `system_controlled`:它调用系统 Edge/Chrome,但使用隔离的临时 -上下文,不复用用户 cookie、已保存密码或旧 SSO 状态,并可预授予登录页访问 loopback -回调的权限。`system` 会打开用户的普通默认浏览器 Profile,仅在用户明确希望复用登录 -状态或受控浏览器不可用时使用;浏览器扩展、旧 cookie 和本地网络权限可能改变授权流程。 +Agent 默认使用 `auto`。普通桌面会话会调用受控 Edge/Chrome;SSH、CI 或无桌面 Linux +会话会输出 `auth_required` 交接信息,而不是尝试启动不可见的浏览器。`system_controlled` +可强制使用隔离的临时浏览器上下文;`system` 会复用用户普通默认浏览器 Profile。 + +交接事件包含一次性 `verification_uri` 和 `callback_port`。`method=ssh_loopback` 或 +`loopback_forwarding` 时,Agent 应提示用户保持原命令运行,并在有浏览器的电脑建立 +同端口 loopback 转发: + +```bash +ssh -N -L 127.0.0.1:<端口>:127.0.0.1:<端口> <同一SSH目标> +``` + +容器等运行时应使用其等价的私有端口转发能力。然后在建立转发的电脑打开一次性地址。 +`--events json` 会把事件以 +`HAPSIGN_EVENT=` 写到 stderr;stdout 仍只在命令结束时输出一个 JSON 文档。 +不得把登录地址写入持久日志,也不得把回调服务暴露到 `0.0.0.0`。 首次申请调试 Profile 需要设备 UDID。可以连接一台已授权调试设备让 HDC 自动读取, 也可以由调用方提供已核实的值: ```bash hapsign sign --hap app.hap --device-udid <64位十六进制UDID> \ - --browser system_controlled --json + --browser auto --json ``` 当日 `metadata.json` 中的 `.p12/.cer/.p7b` 仍有效,且包名、能力模式与已知设备 @@ -124,6 +154,8 @@ hapsign install --hap app-signed.hap --serial --json ## 授权诊断 - 没有任何 `[callback]`:浏览器没有访问 loopback,检查浏览器模式和本地网络权限。 +- `auth_required` 的 `method=ssh_loopback` 或 `loopback_forwarding`:确认本地端口和 + 远端回调端口完全相同,并且浏览器运行在建立转发的那台电脑上。 - 收到 POST/GET 但没有“授权回调校验成功”:检查脱敏后的 CSRF 或参数错误。 - 已校验成功:浏览器继续转圈或随后出现 `net::ERR_ABORTED` 通常是回调后关闭页面产生; 应继续检查 token 交换和后续签名阶段。 diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md index dd43209..658242b 100644 --- a/docs/MIGRATIONS.md +++ b/docs/MIGRATIONS.md @@ -14,7 +14,7 @@ | 编号 | 兼容策略 | 明确结论 | | --- | --- | --- | | 001 | 配置匹配、显式迁移或备份后刷新 | 接受新的缓存一致性校验 | -| 002 | 可配置恢复普通系统浏览器 | 接受 `system_controlled` 新默认值 | +| 002 | 可配置恢复普通系统浏览器 | 接受 `auto` 新默认值 | | 003 | 可配置恢复旧状态与产物目录 | 接受应用目录新默认值 | | 004 | 仅迁移调用方,不提供混合输出模式 | 保留 stdout 结果与 stderr 日志分离 | | 005 | 仅迁移调用方,不提供扁平参数兼容层 | 保留显式子命令接口 | @@ -78,8 +78,9 @@ hapsign inspect --hap app.hap --enable-capability --json ## HAPSIGN-BREAKING-002 -CLI 登录浏览器默认值由 `system` 改为 `system_controlled`。新默认使用隔离的临时 -Edge/Chrome 上下文,不带用户默认浏览器中的 cookie、保存密码、扩展和旧 SSO 状态。 +CLI 登录浏览器默认值由 `system` 改为 `auto`。桌面会话优先使用隔离的临时 +Edge/Chrome 上下文;SSH、CI 或无桌面 Linux 会话输出外部浏览器和安全 loopback +转发指引,不再尝试启动用户看不到的浏览器。 需要旧行为时显式配置: diff --git a/hapsign/cli.py b/hapsign/cli.py index 1452f99..b7d08a6 100644 --- a/hapsign/cli.py +++ b/hapsign/cli.py @@ -18,6 +18,12 @@ from hapsign.cancellation import OperationCancelled from hapsign.config import DEVICE_TYPE_PHONE from hapsign.diagnostics import redact_sensitive_text +from hapsign.login.browser_login import ( + BROWSER_MODES, + DEFAULT_AUTH_TIMEOUT, + AuthEventCallback, + AuthRequiredEvent, +) from hapsign.migrations import ( breaking_changes, cache_compatibility_warning, @@ -41,13 +47,13 @@ from hapsign.signing.installer import Installer from hapsign.token import secure_token_cache -_BROWSER_MODES = ("system", "system_controlled", "playwright") +_AUTH_EVENT_FORMATS = ("human", "json") def _default_browser_mode() -> str: """返回可复现的 CLI 浏览器默认值,并允许显式环境变量覆盖。""" - configured = os.environ.get("HAPSIGN_BROWSER", "system_controlled").lower() - return configured if configured in _BROWSER_MODES else "system_controlled" + configured = os.environ.get("HAPSIGN_BROWSER", "auto").lower() + return configured if configured in BROWSER_MODES else "auto" COMMANDS = { @@ -111,6 +117,59 @@ def _nonempty_serial(value: str) -> str: return serial +def _callback_port(value: str) -> int: + try: + port = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("回调端口必须是整数") from exc + if not 0 <= port <= 65535: + raise argparse.ArgumentTypeError("回调端口必须在 0 到 65535 之间") + return port + + +def _auth_timeout(value: str) -> int: + try: + timeout = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("认证超时必须是整数秒") from exc + if not 30 <= timeout <= 3600: + raise argparse.ArgumentTypeError("认证超时必须在 30 到 3600 秒之间") + return timeout + + +def _add_auth_interaction_options( + parser: argparse.ArgumentParser, + *, + help_prefix: str, +) -> None: + parser.add_argument( + "--browser", + choices=BROWSER_MODES, + default=_default_browser_mode(), + help=f"{help_prefix}浏览器模式;默认 auto(无桌面时交接到外部浏览器)", + ) + parser.add_argument( + "--callback-port", + type=_callback_port, + default=0, + metavar="PORT", + help="loopback 回调端口;默认 0 自动分配,SSH 转发故障时可固定端口", + ) + parser.add_argument( + "--auth-timeout", + type=_auth_timeout, + default=DEFAULT_AUTH_TIMEOUT, + metavar="SECONDS", + help=f"等待浏览器授权的秒数;默认 {DEFAULT_AUTH_TIMEOUT}", + ) + parser.add_argument( + "--events", + choices=_AUTH_EVENT_FORMATS, + default="human", + help="认证中间事件格式;human 或带 HAPSIGN_EVENT= 前缀的 json", + ) + + def _add_hap_identity_options(parser: argparse.ArgumentParser) -> None: parser.add_argument("--hap", required=True, help="HAP 文件的绝对或相对路径") parser.add_argument( @@ -194,12 +253,7 @@ def _add_signing_options( help="签名平台注册的设备类型码;默认 4(手机/平板/2in1)", ) _add_path_options(parser, include_exact_output=True) - parser.add_argument( - "--browser", - choices=_BROWSER_MODES, - default=_default_browser_mode(), - help="首次认证使用的浏览器模式;默认 system_controlled", - ) + _add_auth_interaction_options(parser, help_prefix="首次认证使用的") _add_capability_option(parser) parser.add_argument( "--refresh-token", @@ -293,8 +347,8 @@ def build_parser() -> argparse.ArgumentParser: formatter_class=_formatter, epilog="""\ 示例: - hapsign auth --json # 有缓存则复用,否则打开浏览器 - hapsign auth --refresh --json # 强制重新浏览器认证 + hapsign auth --json # 有缓存则复用,否则自动打开或交接浏览器 + hapsign auth --refresh --json # 强制重新浏览器认证或外部交接 hapsign auth status --json # 只检查本地缓存,不验证服务端有效性 """, ) @@ -306,12 +360,7 @@ def build_parser() -> argparse.ArgumentParser: help="login(默认)或 status", ) auth.add_argument("--country", default="CN", help="华为账号国家码;默认 CN") - auth.add_argument( - "--browser", - choices=_BROWSER_MODES, - default=_default_browser_mode(), - help="认证浏览器模式;默认 system_controlled", - ) + _add_auth_interaction_options(auth, help_prefix="认证") auth.add_argument( "--refresh", action="store_true", @@ -662,6 +711,56 @@ def _run_migrate_cache(args: argparse.Namespace) -> int: return EXIT_OK +def _auth_event_callback(args: argparse.Namespace) -> AuthEventCallback: + event_format = args.events + + def emit(event: AuthRequiredEvent) -> None: + if event_format == "json": + payload = json.dumps(event, ensure_ascii=False, separators=(",", ":")) + print(f"HAPSIGN_EVENT={payload}", file=sys.stderr, flush=True) + return + if event.get("event") != "auth_required": + return + port = int(event["callback_port"]) + print("\n需要在外部浏览器中完成华为账号登录。", file=sys.stderr) + print("请保持当前 HapSign 命令运行。", file=sys.stderr) + if event["method"] == "ssh_loopback": + print("在有浏览器的电脑上另开终端并建立 SSH 转发:", file=sys.stderr) + print( + f" ssh -N -L 127.0.0.1:{port}:127.0.0.1:{port} <同一SSH目标>", + file=sys.stderr, + ) + print("然后在该电脑的浏览器中打开一次性地址:", file=sys.stderr) + elif event["method"] == "loopback_forwarding": + print( + "当前环境没有可见桌面,浏览器电脑必须安全转发到此 loopback 端口。", + file=sys.stderr, + ) + print("若当前主机可通过 SSH 访问,可使用:", file=sys.stderr) + print( + f" ssh -N -L 127.0.0.1:{port}:127.0.0.1:{port} <同一SSH目标>", + file=sys.stderr, + ) + print( + "容器或其他远程运行时请使用其等价的私有端口转发;不要公网暴露端口。", + file=sys.stderr, + ) + print("然后在负责转发的电脑浏览器中打开一次性地址:", file=sys.stderr) + else: + print( + "请在能够访问当前 loopback 回调的浏览器中打开一次性地址:", + file=sys.stderr, + ) + print(f" {event['verification_uri']}", file=sys.stderr) + print( + f"地址将在 {event['expires_in']} 秒后失效;不要分享或写入日志。\n", + file=sys.stderr, + flush=True, + ) + + return emit + + def _auth_pipeline(args: argparse.Namespace) -> SignPipeline: state_dir = str(Path(args.state_dir).expanduser().resolve()) return SignPipeline( @@ -671,6 +770,9 @@ def _auth_pipeline(args: argparse.Namespace) -> SignPipeline: state_dir=state_dir, country=args.country, browser_mode=args.browser, + callback_port=args.callback_port, + auth_timeout=args.auth_timeout, + auth_event_callback=_auth_event_callback(args), keep_signed_hap=False, install_after_sign=False, ) @@ -762,6 +864,9 @@ def _build_sign_pipeline( force_refresh_token=args.refresh_token, force_refresh_signing=args.refresh_signing, browser_mode=args.browser, + callback_port=args.callback_port, + auth_timeout=args.auth_timeout, + auth_event_callback=_auth_event_callback(args), signed_output_dir=str(output_dir), signed_output_path=args.output, overwrite_output=args.overwrite_output, diff --git a/hapsign/login/browser_login.py b/hapsign/login/browser_login.py index 133c4c7..298e1ae 100644 --- a/hapsign/login/browser_login.py +++ b/hapsign/login/browser_login.py @@ -1,4 +1,4 @@ -"""Playwright 浏览器登录模块。 +"""跨平台浏览器登录与 loopback 回调模块。 使用 Playwright 打开华为 OAuth 登录页,用户在浏览器中手动登录, 通过本地 HTTP 服务拦截回调拿到 tempToken。 @@ -12,32 +12,98 @@ 6. 校验 code(CSRF),返回 tempToken """ +import base64 +import hmac import json import logging import os +import platform +import shutil +import socket +import subprocess +import sys import threading import time import uuid import webbrowser +from collections.abc import Callable from email import policy from email.parser import BytesParser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from typing import Literal, TypedDict from urllib.parse import parse_qs, urlparse from hapsign.cancellation import OperationCancelled, raise_if_cancelled from hapsign.config import APP_ID, BASE_URL, LOGIN_AUTH_PATH, LOGIN_SUCCESS_PATH -from hapsign.diagnostics import sensitive_logging_enabled +from hapsign.diagnostics import redact_sensitive_text, sensitive_logging_enabled logger = logging.getLogger(__name__) -# 回调超时(秒)—— 给用户足够时间处理验证码/二次验证 -_CALLBACK_TIMEOUT = 300 +# 回调超时(秒)—— 外部浏览器交接需要给用户留出建立隧道和处理二次验证的时间。 +DEFAULT_AUTH_TIMEOUT = 600 +BROWSER_MODES = ("auto", "external", "system", "system_controlled", "playwright") _CALLBACK_HOST = "127.0.0.1" _MAX_CALLBACK_BODY_SIZE = 64 * 1024 _CONTROLLED_SYSTEM_CHANNELS = ("msedge", "chrome") +class AuthRequiredEvent(TypedDict): + """首次认证需要用户接管浏览器时发布的稳定事件结构。""" + + event: Literal["auth_required"] + method: Literal["manual_loopback", "ssh_loopback", "loopback_forwarding"] + reason: str + verification_uri: str + callback_host: str + callback_port: int + expires_in: int + + +AuthEventCallback = Callable[[AuthRequiredEvent], None] + + +class BrowserUnavailableError(RuntimeError): + """当前会话无法启动可见浏览器,但仍可交接到外部浏览器。""" + + +class _CallbackHTTPServer(ThreadingHTTPServer): + """独占 loopback 端口,避免 Windows 上多个登录会话复用同一端口。""" + + # POSIX 的 SO_REUSEADDR 允许固定端口跨 TIME_WAIT 快速重试,但不会允许两个 + # 活跃 TCP listener 绑定同一地址;Windows 则配合 SO_EXCLUSIVEADDRUSE 禁用它。 + allow_reuse_address = os.name != "nt" + + def server_bind(self) -> None: + if hasattr(socket, "SO_EXCLUSIVEADDRUSE"): + self.socket.setsockopt( + socket.SOL_SOCKET, + socket.SO_EXCLUSIVEADDRUSE, + 1, + ) + super().server_bind() + + +def _browser_environment() -> str: + """识别影响可见浏览器启动方式的运行环境。""" + if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"): + return "ssh" + if os.environ.get("CI"): + return "headless" + if not sys.platform.startswith("linux"): + return "desktop" + release = platform.release().lower() + if ( + os.environ.get("WSL_INTEROP") + or os.environ.get("WSL_DISTRO_NAME") + or "microsoft" in release + ): + return "wsl" + if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"): + return "desktop" + return "headless" + + def _parse_multipart(body: bytes, content_type: str) -> dict[str, str]: """解析浏览器回调的 multipart 表单,兼容 DevEco 的 Netty 解码行为。""" message = BytesParser(policy=policy.default).parsebytes( @@ -152,7 +218,7 @@ def _process_params(self, params: dict) -> None: logger.debug("[callback] sensitive params=%r", params) received_code = params.get("code", "") - if received_code != expected_code: + if not hmac.compare_digest(received_code, expected_code): logger.warning("[callback] CSRF code mismatch") self._send_text(400, "invalid csrf code") return @@ -260,11 +326,22 @@ def __init__( self, browser_mode: str | None = None, cancel_event: threading.Event | None = None, + callback_port: int = 0, + callback_timeout: int = DEFAULT_AUTH_TIMEOUT, + event_callback: AuthEventCallback | None = None, ): self.browser_mode = ( browser_mode or os.environ.get("HAPSIGN_BROWSER", "system_controlled") ).lower() self.cancel_event = cancel_event + if not 0 <= callback_port <= 65535: + raise ValueError("callback_port must be between 0 and 65535") + if callback_timeout <= 0: + raise ValueError("callback_timeout must be greater than zero") + self.callback_port = callback_port + self.callback_timeout = callback_timeout + self.event_callback = event_callback + self._active_callback_port = 0 def login(self, country: str = "CN") -> str: """打开华为登录页,用户手动登录,拦截回调拿 tempToken。 @@ -284,30 +361,55 @@ def login(self, country: str = "CN") -> str: callback_event = threading.Event() handler_class = _make_callback_handler(csrf_code, callback_data, callback_event) # 直接让 HTTPServer 绑定临时端口,消除“先探测、后绑定”的端口竞争窗口。 - server = ThreadingHTTPServer((_CALLBACK_HOST, 0), handler_class) + try: + server = _CallbackHTTPServer( + (_CALLBACK_HOST, self.callback_port), + handler_class, + ) + except OSError as exc: + requested = self.callback_port or "automatic" + raise RuntimeError( + f"Unable to bind callback port {requested} on {_CALLBACK_HOST}: {exc}" + ) from exc server.daemon_threads = True server.block_on_close = False port = server.server_address[1] + self._active_callback_port = port login_url = ( f"{BASE_URL}/{LOGIN_AUTH_PATH}?port={port}&appid={APP_ID}&code={csrf_code}" ) server_thread = threading.Thread(target=server.serve_forever, daemon=True) - server_thread.start() - - logger.info("[LOGIN] callback port=%d", port) - logger.info("[LOGIN] callback server listening on %s:%d", _CALLBACK_HOST, port) - logger.info("[LOGIN] browser mode=%s", self.browser_mode) - if sensitive_logging_enabled(): - logger.debug("[LOGIN] sensitive login_url=%s", login_url) - try: + server_thread.start() + logger.info("[LOGIN] callback port=%d", port) + logger.info( + "[LOGIN] callback server listening on %s:%d", + _CALLBACK_HOST, + port, + ) + logger.info("[LOGIN] browser mode=%s", self.browser_mode) + if sensitive_logging_enabled(): + logger.debug("[LOGIN] sensitive login_url=%s", login_url) + # ── 5. 启动浏览器,等待回调 ── - self._browser_login_and_wait(login_url, callback_event) + try: + self._browser_login_and_wait(login_url, callback_event) + except OperationCancelled: + raise + except Exception: + if not callback_event.is_set(): + raise + # 授权页回调成功后通常立即跳转/关闭,浏览器驱动可能同时报告 + # navigation aborted、target closed 等错误;回调结果才是权威状态。 + logger.info("[login] 已收到授权回调,忽略随后发生的浏览器关闭异常") finally: # ── 6. 关闭 HTTP 服务 ── - server.shutdown() + if server_thread.is_alive(): + server.shutdown() server.server_close() - server_thread.join(timeout=2) + if server_thread.is_alive(): + server_thread.join(timeout=2) + self._active_callback_port = 0 # ── 7. 校验并返回 tempToken ── temp_token = callback_data.get("tempToken", "") @@ -327,6 +429,63 @@ def _browser_login_and_wait( callback_event: threading.Event, ) -> None: """按配置启动系统浏览器或 Playwright。""" + if self.browser_mode == "auto": + environment = _browser_environment() + if environment in {"ssh", "headless"}: + self._external_browser_login_and_wait( + login_url, + callback_event, + reason=environment, + ) + return + if environment == "wsl": + try: + self._wsl_host_browser_login_and_wait(login_url, callback_event) + return + except BrowserUnavailableError as exc: + if callback_event.is_set(): + return + logger.warning( + "[login] WSL 宿主浏览器不可用:%s", + redact_sensitive_text(exc), + ) + self._external_browser_login_and_wait( + login_url, + callback_event, + reason="wsl_browser_unavailable", + ) + return + try: + self._playwright_login_and_wait(login_url, callback_event) + except BrowserUnavailableError as exc: + if callback_event.is_set(): + return + logger.warning( + "[login] 本地受控浏览器不可用:%s", + redact_sensitive_text(exc), + ) + try: + self._system_browser_login_and_wait(login_url, callback_event) + except BrowserUnavailableError as system_exc: + if callback_event.is_set(): + return + logger.warning( + "[login] 系统默认浏览器不可用:%s", + redact_sensitive_text(system_exc), + ) + self._external_browser_login_and_wait( + login_url, + callback_event, + reason="browser_unavailable", + ) + return + if self.browser_mode == "external": + self._external_browser_login_and_wait( + login_url, + callback_event, + reason=_browser_environment(), + ) + return if self.browser_mode == "system": self._system_browser_login_and_wait(login_url, callback_event) return @@ -335,17 +494,64 @@ def _browser_login_and_wait( return raise RuntimeError( f"Unsupported browser mode: {self.browser_mode}. " - "Use 'system_controlled', 'playwright' or 'system'." + "Use 'auto', 'external', 'system_controlled', 'playwright' or 'system'." ) + def _emit_event(self, event: AuthRequiredEvent) -> None: + if self.event_callback is not None: + self.event_callback(event) + return + if event.get("event") == "auth_required": + print( + "External browser login required. Open this one-time URL:\n" + f"{event['verification_uri']}", + file=sys.stderr, + flush=True, + ) + + def _external_browser_login_and_wait( + self, + login_url: str, + callback_event: threading.Event, + *, + reason: str, + ) -> None: + """不启动浏览器,发布一次性交接信息并等待 loopback 回调。""" + raise_if_cancelled(self.cancel_event) + environment = _browser_environment() + if environment == "ssh": + method = "ssh_loopback" + elif environment == "headless": + method = "loopback_forwarding" + else: + method = "manual_loopback" + self._emit_event( + { + "event": "auth_required", + "method": method, + "reason": reason, + "verification_uri": login_url, + "callback_host": _CALLBACK_HOST, + "callback_port": self._active_callback_port, + "expires_in": self.callback_timeout, + } + ) + logger.info( + "[login] 已等待外部浏览器授权:method=%s callback=%s:%d", + method, + _CALLBACK_HOST, + self._active_callback_port, + ) + self._wait_for_callback(callback_event) + def _wait_for_callback(self, callback_event: threading.Event) -> None: - deadline = time.monotonic() + _CALLBACK_TIMEOUT + deadline = time.monotonic() + self.callback_timeout while not callback_event.wait(timeout=0.1): raise_if_cancelled(self.cancel_event) if time.monotonic() >= deadline: raise RuntimeError( "Login timed out: no callback received within " - f"{_CALLBACK_TIMEOUT}s. " + f"{self.callback_timeout}s. " "Please check your network and complete login in the browser." ) @@ -356,19 +562,77 @@ def _system_browser_login_and_wait( ) -> None: """使用系统默认浏览器完成登录,不引入 Chromium 运行时。""" raise_if_cancelled(self.cancel_event) - if not webbrowser.open(login_url, new=1, autoraise=True): - raise RuntimeError( + try: + opened = webbrowser.open(login_url, new=1, autoraise=True) + except (OSError, webbrowser.Error) as exc: + raise BrowserUnavailableError( + f"Unable to open the system browser: {redact_sensitive_text(exc)}" + ) from exc + if not opened: + raise BrowserUnavailableError( "Unable to open the system browser. " "Set HAPSIGN_BROWSER=playwright to use the optional backend." ) logger.info("[login] 登录页面已在系统浏览器中打开") logger.info( "[login] 正在等待登录回调(最多 %s 秒,可处理验证码或二次验证)", - _CALLBACK_TIMEOUT, + self.callback_timeout, ) raise_if_cancelled(self.cancel_event) self._wait_for_callback(callback_event) + def _wsl_host_browser_login_and_wait( + self, + login_url: str, + callback_event: threading.Event, + ) -> None: + """从 WSL 调用 Windows 宿主浏览器,并在 WSL loopback 等待回调。""" + raise_if_cancelled(self.cancel_event) + commands: list[tuple[str, list[str]]] = [] + wslview = shutil.which("wslview") + if wslview: + commands.append(("wslview", [wslview, login_url])) + powershell = shutil.which("powershell.exe") + if powershell: + quoted_url = login_url.replace("'", "''") + encoded_command = base64.b64encode( + f"Start-Process -FilePath '{quoted_url}'".encode("utf-16le") + ).decode("ascii") + commands.append( + ( + "powershell.exe", + [ + powershell, + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + encoded_command, + ], + ) + ) + failures: list[str] = [] + for runtime_name, command in commands: + try: + result = subprocess.run( + command, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + ) + except (OSError, subprocess.SubprocessError) as exc: + # TimeoutExpired 等异常会把完整 argv 放进字符串;wslview argv + # 含一次性 URL,PowerShell argv 含其可逆编码,因此只记录类型。 + failures.append(f"{runtime_name}: {type(exc).__name__}") + continue + if result.returncode == 0: + logger.info("[login] 登录页面已通过 %s 在 Windows 中打开", runtime_name) + self._wait_for_callback(callback_event) + return + failures.append(f"{runtime_name}: exit {result.returncode}") + detail = " | ".join(failures) if failures else "no WSL browser bridge found" + raise BrowserUnavailableError(detail) + def _playwright_login_and_wait( self, login_url: str, @@ -391,19 +655,28 @@ def _playwright_login_and_wait( from playwright.sync_api import TimeoutError as PwTimeout from playwright.sync_api import sync_playwright except ImportError as exc: - raise RuntimeError( + raise BrowserUnavailableError( "Playwright not installed. Run:\n" " pip install playwright\n" " playwright install chromium" ) from exc + page_ready = False try: with sync_playwright() as pw: - browser, runtime_name = _launch_controlled_browser( - pw, - self.browser_mode, - allow_fallback=True, + preferred_mode = ( + "system_controlled" + if self.browser_mode == "auto" + else self.browser_mode ) + try: + browser, runtime_name = _launch_controlled_browser( + pw, + preferred_mode, + allow_fallback=True, + ) + except RuntimeError as exc: + raise BrowserUnavailableError(str(exc)) from exc logger.info("[login] 受控浏览器已启动:%s", runtime_name) context = browser.new_context( viewport={"width": 1280, "height": 800}, @@ -439,28 +712,38 @@ def log_request_failure(request) -> None: page.on("requestfailed", log_request_failure) page.on( "pageerror", - lambda error: logger.warning("[login] 页面脚本错误:%s", error), + lambda error: logger.warning( + "[login] 页面脚本错误:%s", + redact_sensitive_text(error), + ), ) # 使用 domcontentloaded,避免持续网络请求让 networkidle 超时。 try: page.goto(login_url, wait_until="domcontentloaded", timeout=60000) except PwTimeout as exc: - raise RuntimeError("Login page load timed out") from exc + raise BrowserUnavailableError("Login page load timed out") from exc + page_ready = True logger.info("[login] 登录页面已打开,请在浏览器中完成登录") logger.info( "[login] 正在等待登录回调(最多 %s 秒,可处理验证码或二次验证)", - _CALLBACK_TIMEOUT, + self.callback_timeout, ) self._wait_for_callback(callback_event) browser.close() - except (RuntimeError, OperationCancelled): + except (BrowserUnavailableError, OperationCancelled): raise - except Exception as e: - raise RuntimeError(f"Browser operation failed: {e}") from e + except Exception as exc: + if not page_ready: + raise BrowserUnavailableError( + f"Controlled browser startup failed: {exc}" + ) from exc + if isinstance(exc, RuntimeError): + raise + raise RuntimeError(f"Browser operation failed: {exc}") from exc def _launch_controlled_browser( diff --git a/hapsign/migrations.py b/hapsign/migrations.py index 1ce3539..aef64f5 100644 --- a/hapsign/migrations.py +++ b/hapsign/migrations.py @@ -44,8 +44,11 @@ "decision": "accepted", "compatibility_strategy": "configuration", "compatibility_options": ("--browser system", "HAPSIGN_BROWSER=system"), - "summary": "CLI 默认浏览器由 system 改为 system_controlled", - "impact": "授权使用隔离的 Edge/Chrome 上下文,不复用默认浏览器的 cookie。", + "summary": "CLI 默认浏览器由 system 改为 auto", + "impact": ( + "桌面授权优先使用隔离浏览器;SSH、CI 或无桌面 Linux 会话改为" + "外部浏览器交接。" + ), "remediation": ( "需要原行为时传入 --browser system,或设置 HAPSIGN_BROWSER=system。" ), diff --git a/hapsign/pipeline.py b/hapsign/pipeline.py index 11cacf4..68d1d9f 100644 --- a/hapsign/pipeline.py +++ b/hapsign/pipeline.py @@ -38,7 +38,11 @@ KEYSTORE_PASSWORD, ) from hapsign.diagnostics import is_valid_device_udid, redact_sensitive_text -from hapsign.login.browser_login import BrowserLogin +from hapsign.login.browser_login import ( + DEFAULT_AUTH_TIMEOUT, + AuthEventCallback, + BrowserLogin, +) from hapsign.models import AppBriefInfo, CertResult, ProvisionResult, TokenInfo from hapsign.signing.hap_inspect import is_hap_signed from hapsign.signing.hap_signer import HapSigner @@ -99,6 +103,9 @@ def __init__( serial: str | None = None, install_after_sign: bool = True, *, + callback_port: int = 0, + auth_timeout: int = DEFAULT_AUTH_TIMEOUT, + auth_event_callback: AuthEventCallback | None = None, device_udid: str = "", signed_output_path: str = "", overwrite_output: bool = False, @@ -113,6 +120,9 @@ def __init__( self.force_refresh_token = force_refresh_token self.force_refresh_signing = force_refresh_signing self.browser_mode = browser_mode + self.callback_port = callback_port + self.auth_timeout = auth_timeout + self.auth_event_callback = auth_event_callback self.keep_signed_hap = keep_signed_hap self.install_after_sign = install_after_sign self.device_udid = device_udid.strip() @@ -586,6 +596,9 @@ def _step_login(self) -> None: login = BrowserLogin( browser_mode=self.browser_mode, cancel_event=self.cancel_event, + callback_port=self.callback_port, + callback_timeout=self.auth_timeout, + event_callback=self.auth_event_callback, ) self._temp_token = login.login(self.country) diff --git a/tests/test_browser_login.py b/tests/test_browser_login.py index 3775759..84aa4e9 100644 --- a/tests/test_browser_login.py +++ b/tests/test_browser_login.py @@ -5,7 +5,7 @@ import threading from http.server import ThreadingHTTPServer from unittest.mock import Mock -from urllib.parse import urlencode +from urllib.parse import parse_qs, urlencode, urlparse from hapsign.cancellation import OperationCancelled from hapsign.diagnostics import set_sensitive_logging @@ -51,6 +51,12 @@ def test_callback_host_is_loopback() -> None: assert browser_login._CALLBACK_HOST == "127.0.0.1" +def test_callback_server_uses_platform_appropriate_reuse_policy() -> None: + assert browser_login._CallbackHTTPServer.allow_reuse_address is ( + browser_login.os.name != "nt" + ) + + def test_callback_accepts_get_on_root_path() -> None: callback_data = {} callback_event = threading.Event() @@ -271,6 +277,353 @@ def test_default_browser_mode_is_controlled_system_browser(monkeypatch) -> None: assert browser_login.BrowserLogin().browser_mode == "system_controlled" +def test_browser_environment_prefers_ssh_over_forwarded_display(monkeypatch) -> None: + monkeypatch.setattr(browser_login.sys, "platform", "linux") + monkeypatch.setenv("SSH_CONNECTION", "192.0.2.1 12345 192.0.2.2 22") + monkeypatch.setenv("DISPLAY", "localhost:10.0") + + assert browser_login._browser_environment() == "ssh" + + +def test_browser_environment_detects_ssh_on_non_linux(monkeypatch) -> None: + monkeypatch.setattr(browser_login.sys, "platform", "darwin") + monkeypatch.setenv("SSH_TTY", "/dev/ttys001") + + assert browser_login._browser_environment() == "ssh" + + +def test_browser_environment_detects_ci_before_desktop(monkeypatch) -> None: + monkeypatch.setattr(browser_login.sys, "platform", "win32") + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.setenv("CI", "true") + + assert browser_login._browser_environment() == "headless" + + +def test_browser_environment_detects_headless_linux(monkeypatch) -> None: + monkeypatch.setattr(browser_login.sys, "platform", "linux") + monkeypatch.setattr(browser_login.platform, "release", lambda: "6.8.0-generic") + for name in ( + "SSH_CONNECTION", + "SSH_TTY", + "WSL_INTEROP", + "WSL_DISTRO_NAME", + "DISPLAY", + "WAYLAND_DISPLAY", + "CI", + ): + monkeypatch.delenv(name, raising=False) + + assert browser_login._browser_environment() == "headless" + + +def test_auto_headless_emits_external_handoff_without_launching_browser( + monkeypatch, +) -> None: + monkeypatch.setattr(browser_login, "_browser_environment", lambda: "headless") + events = [] + callback_event = threading.Event() + callback_event.set() + login = browser_login.BrowserLogin( + browser_mode="auto", + event_callback=events.append, + ) + login._active_callback_port = 43123 + monkeypatch.setattr( + login, + "_playwright_login_and_wait", + Mock(side_effect=AssertionError("browser must not be launched")), + ) + + login._browser_login_and_wait("https://example.invalid/login", callback_event) + + assert events == [ + { + "event": "auth_required", + "method": "loopback_forwarding", + "reason": "headless", + "verification_uri": "https://example.invalid/login", + "callback_host": "127.0.0.1", + "callback_port": 43123, + "expires_in": browser_login.DEFAULT_AUTH_TIMEOUT, + } + ] + + +def test_auto_desktop_falls_back_to_system_browser(monkeypatch) -> None: + monkeypatch.setattr(browser_login, "_browser_environment", lambda: "desktop") + callback_event = threading.Event() + login = browser_login.BrowserLogin(browser_mode="auto") + controlled = Mock(side_effect=browser_login.BrowserUnavailableError("missing")) + system = Mock() + monkeypatch.setattr(login, "_playwright_login_and_wait", controlled) + monkeypatch.setattr(login, "_system_browser_login_and_wait", system) + + login._browser_login_and_wait("https://example.invalid/login", callback_event) + + controlled.assert_called_once() + system.assert_called_once_with("https://example.invalid/login", callback_event) + + +def test_auto_browser_fallback_redacts_login_state(monkeypatch, caplog) -> None: + monkeypatch.setattr(browser_login, "_browser_environment", lambda: "desktop") + callback_event = threading.Event() + login = browser_login.BrowserLogin(browser_mode="auto") + secret = "sensitive-csrf-value" + monkeypatch.setattr( + login, + "_playwright_login_and_wait", + Mock( + side_effect=browser_login.BrowserUnavailableError( + f"failed at https://example.invalid/login?code={secret}" + ) + ), + ) + system = Mock() + monkeypatch.setattr(login, "_system_browser_login_and_wait", system) + + caplog.set_level(logging.WARNING) + login._browser_login_and_wait("https://example.invalid/login", callback_event) + + assert secret not in caplog.text + assert "code=" in caplog.text + system.assert_called_once() + + +def test_auto_does_not_reopen_browser_after_callback(monkeypatch) -> None: + monkeypatch.setattr(browser_login, "_browser_environment", lambda: "desktop") + callback_event = threading.Event() + callback_event.set() + login = browser_login.BrowserLogin(browser_mode="auto") + monkeypatch.setattr( + login, + "_playwright_login_and_wait", + Mock(side_effect=browser_login.BrowserUnavailableError("target closed")), + ) + system = Mock() + monkeypatch.setattr(login, "_system_browser_login_and_wait", system) + + login._browser_login_and_wait("https://example.invalid/login", callback_event) + + system.assert_not_called() + + +def test_auto_desktop_falls_back_to_external_handoff(monkeypatch) -> None: + monkeypatch.setattr(browser_login, "_browser_environment", lambda: "desktop") + callback_event = threading.Event() + login = browser_login.BrowserLogin(browser_mode="auto") + login._active_callback_port = 43123 + monkeypatch.setattr( + login, + "_playwright_login_and_wait", + Mock(side_effect=browser_login.BrowserUnavailableError("controlled missing")), + ) + monkeypatch.setattr( + login, + "_system_browser_login_and_wait", + Mock(side_effect=browser_login.BrowserUnavailableError("system missing")), + ) + external = Mock() + monkeypatch.setattr(login, "_external_browser_login_and_wait", external) + + login._browser_login_and_wait("https://example.invalid/login", callback_event) + + external.assert_called_once_with( + "https://example.invalid/login", + callback_event, + reason="browser_unavailable", + ) + + +def test_auto_wsl_falls_back_to_manual_handoff(monkeypatch) -> None: + monkeypatch.setattr(browser_login, "_browser_environment", lambda: "wsl") + callback_event = threading.Event() + login = browser_login.BrowserLogin(browser_mode="auto") + login._active_callback_port = 43123 + monkeypatch.setattr( + login, + "_wsl_host_browser_login_and_wait", + Mock(side_effect=browser_login.BrowserUnavailableError("bridge missing")), + ) + external = Mock() + monkeypatch.setattr(login, "_external_browser_login_and_wait", external) + + login._browser_login_and_wait("https://example.invalid/login", callback_event) + + external.assert_called_once_with( + "https://example.invalid/login", + callback_event, + reason="wsl_browser_unavailable", + ) + + +def test_wsl_powershell_launcher_encodes_url_instead_of_command_arguments( + monkeypatch, +) -> None: + monkeypatch.setattr( + browser_login.shutil, + "which", + lambda name: ( + "/mnt/c/Windows/powershell.exe" if name == "powershell.exe" else None + ), + ) + completed = Mock(returncode=0) + run = Mock(return_value=completed) + monkeypatch.setattr(browser_login.subprocess, "run", run) + callback_event = threading.Event() + callback_event.set() + login_url = "https://example.invalid/apply?port=43123&appid=1007&code=abc" + login = browser_login.BrowserLogin(browser_mode="auto") + + login._wsl_host_browser_login_and_wait(login_url, callback_event) + + command = run.call_args.args[0] + assert "-EncodedCommand" in command + assert login_url not in command + encoded = command[command.index("-EncodedCommand") + 1] + decoded = browser_login.base64.b64decode(encoded).decode("utf-16le") + assert decoded == f"Start-Process -FilePath '{login_url}'" + + +def test_wsl_launcher_failure_does_not_expose_one_time_url(monkeypatch) -> None: + monkeypatch.setattr( + browser_login.shutil, + "which", + lambda name: "/usr/bin/wslview" if name == "wslview" else None, + ) + secret = "sensitive-csrf-value" + login_url = f"https://example.invalid/apply?code={secret}" + monkeypatch.setattr( + browser_login.subprocess, + "run", + Mock( + side_effect=browser_login.subprocess.TimeoutExpired( + ["/usr/bin/wslview", login_url], + 15, + ) + ), + ) + login = browser_login.BrowserLogin(browser_mode="auto") + + try: + login._wsl_host_browser_login_and_wait(login_url, threading.Event()) + except browser_login.BrowserUnavailableError as exc: + assert secret not in str(exc) + assert "TimeoutExpired" in str(exc) + else: + raise AssertionError("failed WSL launcher should request external handoff") + + +def test_external_handoff_completes_through_loopback_callback(monkeypatch) -> None: + monkeypatch.setattr(browser_login, "_browser_environment", lambda: "ssh") + events = [] + event_ready = threading.Event() + result = {} + + def capture_event(event) -> None: + events.append(event) + event_ready.set() + + login = browser_login.BrowserLogin( + browser_mode="external", + callback_timeout=5, + event_callback=capture_event, + ) + + def run_login() -> None: + try: + result["token"] = login.login() + except Exception as exc: # pragma: no cover - assertion below preserves detail + result["error"] = exc + + worker = threading.Thread(target=run_login, daemon=True) + worker.start() + assert event_ready.wait(timeout=2) + event = events[0] + parsed = urlparse(str(event["verification_uri"])) + code = parse_qs(parsed.query)["code"][0] + connection = http.client.HTTPConnection( + str(event["callback_host"]), + int(event["callback_port"]), + timeout=5, + ) + connection.request( + "POST", + "/callback", + urlencode({"tempToken": "external-token", "code": code}), + {"Content-Type": "application/x-www-form-urlencoded"}, + ) + response = connection.getresponse() + response.read() + connection.close() + worker.join(timeout=3) + + assert response.status == 302 + assert result == {"token": "external-token"} + assert event["method"] == "ssh_loopback" + assert login._active_callback_port == 0 + + +def test_valid_callback_wins_over_browser_shutdown_error(monkeypatch) -> None: + login = browser_login.BrowserLogin(browser_mode="system") + + def callback_then_fail(login_url, callback_event) -> None: + parsed = urlparse(login_url) + query = parse_qs(parsed.query) + connection = http.client.HTTPConnection( + browser_login._CALLBACK_HOST, + int(query["port"][0]), + timeout=5, + ) + connection.request( + "POST", + "/callback", + urlencode( + { + "tempToken": "callback-won-token", + "code": query["code"][0], + } + ), + {"Content-Type": "application/x-www-form-urlencoded"}, + ) + response = connection.getresponse() + response.read() + connection.close() + assert response.status == 302 + assert callback_event.wait(timeout=2) + raise RuntimeError("target closed after callback") + + monkeypatch.setattr(login, "_browser_login_and_wait", callback_then_fail) + + assert login.login() == "callback-won-token" + + +def test_fixed_callback_port_reports_bind_conflict() -> None: + handler = browser_login._make_callback_handler( + "occupied", + {}, + threading.Event(), + ) + occupied = ThreadingHTTPServer((browser_login._CALLBACK_HOST, 0), handler) + port = occupied.server_address[1] + login = browser_login.BrowserLogin( + browser_mode="external", + callback_port=port, + callback_timeout=1, + ) + + try: + try: + login.login() + except RuntimeError as exc: + assert f"callback port {port}" in str(exc) + else: + raise AssertionError("occupied callback port should fail before login") + finally: + occupied.server_close() + + def test_controlled_system_browser_prefers_edge() -> None: browser = Mock() chromium = Mock() diff --git a/tests/test_cli.py b/tests/test_cli.py index 9d89391..ebe2a92 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -128,7 +128,7 @@ def run(self): assert captured["bundle_name"] == "com.example.app" assert captured["serial"] == "device-serial" assert captured["install_after_sign"] is True - assert captured["browser_mode"] == "system_controlled" + assert captured["browser_mode"] == "auto" assert Path(captured["state_dir"]) == state_dir assert Path(captured["signed_output_dir"]) == output_dir if pipeline_result: @@ -378,7 +378,145 @@ def test_cli_browser_default_allows_valid_environment_override(monkeypatch) -> N monkeypatch.setenv("HAPSIGN_BROWSER", "system") assert cli.build_parser().parse_args(["auth"]).browser == "system" monkeypatch.setenv("HAPSIGN_BROWSER", "invalid") - assert cli.build_parser().parse_args(["auth"]).browser == "system_controlled" + assert cli.build_parser().parse_args(["auth"]).browser == "auto" + + +@pytest.mark.parametrize( + ("option", "value"), + [ + ("--callback-port", "not-a-port"), + ("--callback-port", "-1"), + ("--callback-port", "65536"), + ("--auth-timeout", "not-a-timeout"), + ("--auth-timeout", "29"), + ("--auth-timeout", "3601"), + ], +) +def test_auth_interaction_options_reject_invalid_values(option, value, capsys) -> None: + result = cli.main(["auth", option, value, "--json"]) + + assert result == cli.EXIT_USAGE + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is False + assert payload["error"]["type"] == "invalid_arguments" + + +def test_auth_interaction_options_accept_boundary_values() -> None: + low = cli.build_parser().parse_args( + ["auth", "--callback-port", "0", "--auth-timeout", "30"] + ) + high = cli.build_parser().parse_args( + ["auth", "--callback-port", "65535", "--auth-timeout", "3600"] + ) + + assert (low.callback_port, low.auth_timeout) == (0, 30) + assert (high.callback_port, high.auth_timeout) == (65535, 3600) + + +def test_cli_auth_interaction_options_are_forwarded( + monkeypatch, tmp_path, capsys +) -> None: + captured = {} + + class FakePipeline: + def __init__(self, **kwargs): + captured.update(kwargs) + + def authenticate(self, force_refresh=False): + captured["force_refresh"] = force_refresh + captured["auth_event_callback"]( + { + "event": "auth_required", + "method": "ssh_loopback", + "reason": "ssh", + "verification_uri": "https://example.invalid/one-time", + "callback_host": "127.0.0.1", + "callback_port": 43123, + "expires_in": 900, + } + ) + return {"authenticated": True, "from_cache": False} + + def auth_status(self): + return { + "authenticated": True, + "cache_path": tmp_path / ".token_cache.json", + } + + monkeypatch.setattr(cli, "SignPipeline", FakePipeline) + + result = cli.main( + [ + "auth", + "--browser", + "external", + "--callback-port", + "43123", + "--auth-timeout", + "900", + "--events", + "json", + "--state-dir", + str(tmp_path), + "--json", + ] + ) + + assert result == 0 + assert captured["browser_mode"] == "external" + assert captured["callback_port"] == 43123 + assert captured["auth_timeout"] == 900 + assert callable(captured["auth_event_callback"]) + output = capsys.readouterr() + assert json.loads(output.out)["ok"] is True + event_line = next( + line for line in output.err.splitlines() if line.startswith("HAPSIGN_EVENT=") + ) + event = json.loads(event_line.removeprefix("HAPSIGN_EVENT=")) + assert event["verification_uri"] == "https://example.invalid/one-time" + + +def test_json_auth_event_has_stable_prefix(capsys) -> None: + args = cli.build_parser().parse_args(["auth", "--events", "json"]) + callback = cli._auth_event_callback(args) + callback( + { + "event": "auth_required", + "method": "ssh_loopback", + "reason": "ssh", + "verification_uri": "https://example.invalid/one-time", + "callback_host": "127.0.0.1", + "callback_port": 43123, + "expires_in": 600, + } + ) + + stderr = capsys.readouterr().err.strip() + assert stderr.startswith("HAPSIGN_EVENT=") + event = json.loads(stderr.removeprefix("HAPSIGN_EVENT=")) + assert event["event"] == "auth_required" + assert event["callback_port"] == 43123 + + +def test_headless_auth_event_explains_loopback_forwarding(capsys) -> None: + args = cli.build_parser().parse_args(["auth"]) + callback = cli._auth_event_callback(args) + callback( + { + "event": "auth_required", + "method": "loopback_forwarding", + "reason": "headless", + "verification_uri": "https://example.invalid/one-time", + "callback_host": "127.0.0.1", + "callback_port": 43123, + "expires_in": 600, + } + ) + + stderr = capsys.readouterr().err + assert "ssh -N -L 127.0.0.1:43123:127.0.0.1:43123" in stderr + assert "不要公网暴露端口" in stderr + assert "https://example.invalid/one-time" in stderr def test_sign_rejects_serial_and_device_udid_together(capsys) -> None: @@ -439,7 +577,7 @@ def run(self): assert payload["ok"] is True assert payload["command"] == "sign" assert payload["installed"] is False - assert payload["browser_mode"] == "system_controlled" + assert payload["browser_mode"] == "auto" assert payload["migration_warnings"] == [] assert Path(payload["signed_hap"]).is_absolute() assert captured["install_after_sign"] is False