@luckypiggy/zhihu-ai-cli 0.1.0-test.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # @luckypiggy/zhihu-ai-cli — 智护 Pilot CLI 测试发布包
2
+
3
+ 智护 Pilot CLI P1 只读能力切片提供安全的本机认证闭环:
4
+
5
+ - `zhihu auth login`:生成 S256 PKCE,打开客户自己的 Pilot 授权页,轮询一次性登录事务,并把仅有的 Pilot CLI Refresh Credential 写入 macOS Keychain 或 Linux Secret Service;
6
+ - `zhihu auth status`:使用本机凭据安全轮换一次,显示 profile、用户/租户、Scope 和 Access Token 到期时间;
7
+ - `zhihu auth logout`:先请求 Pilot 撤销会话,再删除本机凭据;网络失败时保留凭据供重试;
8
+ - `zhihu capabilities list`:通过固定只读网关列出当前登录用户的 canonical Scope 和能力;遇到一次 401 只在 profile 锁内刷新并重试一次;
9
+ - `zhihu employees list`:通过固定只读网关列出当前用户可见的安全数字员工字段;只读请求同样复用 profile 锁内一次 401 重试;
10
+ - `zhihu employees chat <employee-id> --message <text> [--conversation <id>]`:调用 manifest 中允许的只读数字员工并流式输出安全回答;首次请求返回的 opaque `conversation_id` 会在人工模式写到 stderr,后续用 `--conversation` 续接;`--json` 只输出一个最终 envelope;Ctrl+C/断连会尽力取消下游生成;
11
+ - `zhihu assets list [--keyword <text>] [--model <name|code|id>] [--page N] [--page-size N]`:通过固定资产只读网关查询安全投影;模型文本只做名称/编码精确匹配,页码和响应字段严格校验,401 最多刷新并重试一次;
12
+ - `zhihu assets get --id <id>`:通过固定资产详情只读网关读取一个安全投影;资产 ID、响应字段和详情 envelope 严格校验,401 最多刷新并重试一次;
13
+ - `zhihu metrics query --asset <id|ip> --metric <online|cpu|memory|load|disk|io|snmp> [--window 5m..24h] [--max-points N]`:通过固定指标定义查询 VictoriaMetrics;资产 ID 先解析为受控监控 IP,PromQL、VM URL 和标签选择均由 Pilot 服务端固定,响应只返回有限统计和采样点;
14
+ - `zhihu alarms list [--status all|firing|resolved] [--page N] [--page-size N]`:通过固定告警只读网关分页查询安全投影;状态、分页和响应字段严格校验,401 最多刷新并重试一次;
15
+ - `zhihu alarms get --id <id>`:通过固定告警详情只读网关读取单条安全投影;告警 ID、详情 envelope 和响应身份严格校验,401 最多刷新并重试一次;
16
+ - `zhihu incidents diagnose --alarm-id <id>`:通过固定指定告警诊断网关生成结构化证据、确定性分类、稳定结论/置信度/发现/动作 code;仅接受 active signed session 与 `incidents:analyze`、`alarms:read`、`assets:read`、`metrics:read`,固定读取告警详情、精确资产 ID/明确告警 IP 和 allowlist 指标的有界告警事件窗口与最近窗口;客户端不能提供 URL、PromQL、时间窗口、模型提示或 SSH,服务端不调用 LLM、不写数据,30 秒总超时且 401 最多刷新一次;
17
+ - `zhihu inspections run [--model <name|code|id>] [--window 5m..24h]`:通过固定 `/api/zhihu/integrations/v1/inspections/run` 生成结构化 `grade`/`findings`/`actions` 巡检摘要;仅接受 active signed session 与 `inspections:run-readonly`、`assets:read`、`metrics:read`、`alarms:read`,资产和告警各最多 100 条,VM 仅使用 `host_ip`、`instance`、`monitorIp`、`ip`、`agent_hostname` 五类标签并最多解析 10,000 个身份,告警仅按精确资产 ID/IP 关联;客户端不能提供 query 参数、URL、PromQL、任意标签或写操作,30 秒总超时且 401 最多刷新一次;
18
+ - `zhihu install [--base-url <https-url>] [--profile <name>] [--timeout N] [--language zh-CN|en] [--ca-file <absolute-file>] [--codex-skill-dir <absolute-path>] [--skip-login] [--skip-codex] [--force]`:交互 TTY 或显式参数安装向导;按“先安装随包 `zhihu-aiops` Skill → 引导环境配置 → 严格 `/info` 兼容性检查 → 原子写 Profile → 浏览器 OAuth → `doctor`”顺序执行,失败时恢复本地 Profile;非交互/JSON 模式必须显式提供 `--base-url` 和 `--skip-login`;CA 文件只保存绝对路径,不保存证书内容;
19
+ - `zhihu doctor`:结构化检查 Profile/base URL/TLS/CA、OS 凭据存储、Pilot `/api/zhihu/integrations/v1/info` 兼容性、登录状态、能力和 Codex Skill 状态,不会触发登录;
20
+ - `zhihu install codex` / `zhihu uninstall codex`:在临时或用户指定的 Codex Skill 目录原子安装、升级和卸载无密钥的 `zhihu-aiops` Skill;受管文件被修改或目标目录不是本 CLI 创建的安装时默认拒绝,`--force` 仅用于受管安装;
21
+ - `zhihu version`、`zhihu config init`、`zhihu config show --redact` 继续可用。
22
+
23
+ 需要访问已配置环境的命令支持 `--profile`,各命令按帮助说明使用 `--json`。JSON stdout 始终只有一个 success/error envelope;Access/Refresh Token、PKCE secret、授权码和上游正文不会写入 stdout、stderr、日志或 Profile。浏览器无法自动打开时,授权 URL 只作为 stderr 的人工操作提示输出。
24
+
25
+ 当前客户端只访问当前 Profile 的固定 `/api/zhihu/integrations/v1` 路径,拒绝任意 HTTP 重定向和非回环明文 HTTP;Profile 的 CA bundle 用于严格 TLS 校验。macOS 使用 `/usr/bin/security`,Linux 使用固定路径 `/usr/bin/secret-tool`,不提供明文文件 fallback。Refresh rotation 通过 profile 锁和进程内 single-flight 合并,并在 Keychain 写入失败时尝试撤销新会话和清理本机旧值。
26
+
27
+ 本切片提供数字员工、资产、指标、告警、指定告警诊断和巡检只读 `list`/`get`/`query`/`chat`/`diagnose`/`run`,并在包内实现无密钥的外部 `zhihu-aiops` Codex Skill 及其受管安装/卸载,以及本地 `zhihu install` 安装向导。Skill 只调用 `zhihu ... --json`,不读取本机认证材料,不提供写操作或 MCP;客户 URL、npm 发布和正式联调遵循各自发布门禁。安装向导严格验证 `/info` 的服务、协议、版本、能力顺序和 OAuth BFF 配置,拒绝非回环明文 HTTP、重定向、userinfo、query/fragment、符号链接 CA;Profile 与 root current_profile 采用快照恢复,Skill 安装遵守 foreign/modified/force 语义。指定告警诊断只允许 `cpu`/`memory`/`load`/`io`/`disk`/`online`/`unknown` 确定性分类,巡检只允许固定模型/窗口、四个 Scope、资产/告警各 100 条和五类 VM IP 标签,证据不足时保守返回;两条链路均不会接受客户端 URL、PromQL、任意标签或写操作,不调用 LLM,均为 30 秒总超时且 401 最多刷新一次。CLI 只解析签名 JWT 的非敏感展示字段,不以本地解析结果作为授权决策。Chat conversation 映射当前是服务端单 worker 的有界 TTL 内存状态,同进程并发有锁保护;多 worker/多副本部署需要共享状态存储。
28
+
29
+ ## 公网安装契约
30
+
31
+ 目标体验命令为:
32
+
33
+ ```bash
34
+ npx @luckypiggy/zhihu-ai-cli@test install
35
+ ```
36
+
37
+ 该命令是包的公网安装入口;客户 URL 和离线 `.tgz` 适用于受控网络或隔离环境。发布与客户联调按批准的发布门禁执行。
38
+
39
+ `install` 的固定顺序是:
40
+
41
+ 1. 先安装或确认包内 bundled `zhihu-aiops` Skill;Skill 失败时不发起网络请求、不提示环境配置,也不写 Profile。
42
+ 2. 引导环境配置,解析 CLI 参数、非空环境变量以及交互/default 值。
43
+ 3. 对客户地址执行严格 `/info` 预检,确认服务、协议、版本、能力和 OAuth BFF 契约。
44
+ 4. 预检通过后原子写入 Profile/root 指针。
45
+ 5. 使用浏览器 OAuth/PKCE 完成登录;令牌不从环境变量或命令行读取,CLI Refresh Credential 只保存到 macOS Keychain 或 Linux Secret Service,Access Token 只在进程内存中短期缓存。
46
+ 6. 登录流程结束后运行 `doctor`,输出配置、服务契约、凭据状态和 Skill 状态。
47
+
48
+ 安装向导只支持以下非敏感环境变量,且空值或全空白值按未设置处理:
49
+
50
+ | 环境变量 | 允许值/用途 |
51
+ | --- | --- |
52
+ | `ZHIHU_PROFILE` | Profile 名称 |
53
+ | `ZHIHU_BASE_URL` | 客户智护 HTTPS 地址 |
54
+ | `ZHIHU_AUTH_MODE=browser` | 唯一支持的认证模式 |
55
+ | `ZHIHU_CA_BUNDLE` | 客户 CA bundle 绝对路径 |
56
+ | `ZHIHU_TIMEOUT` | 请求超时秒数(1~300) |
57
+
58
+ 配置优先级固定为“CLI 参数 > 非空 env > 交互/default”。不支持也不建议 `ZHIHU_ACCESS_TOKEN`、`ZHIHU_USER` 或 `ZHIHU_PASSWORD`;浏览器 OAuth 是唯一认证路径,令牌只通过浏览器 OAuth 获得并由系统凭据库保存 CLI Refresh Credential。安装失败或服务尚未配置时可再次运行 `zhihu install`,向导会保留/恢复一致的本地 Profile。npm `install`/`postinstall` 没有交互钩子,不会在依赖安装阶段启动向导、读取环境变量或打开浏览器。
59
+
60
+ 开发和发布需要 Node.js 22 或更新版本:
61
+
62
+ ```bash
63
+ npm test
64
+ npm pack --dry-run
65
+ ```
66
+
67
+ 发布包只包含 `bin/`、编译后的 `dist/*.js`、无密钥的 `assets/codex-skills/`、README 和 package manifest,不包含源码、测试、凭据或 `postinstall` 脚本。正式联调需要 HTTPS 客户地址、真实客户契约(`/info`、OAuth/auth、employees chat、assets、metrics、alarms、incidents diagnose、inspections run)和客户 OS 凭据服务;本地测试覆盖 parser、取消、错误、安装向导非交互校验、Profile 回滚、Skill 安装/卸载和 npm 白名单。客户 URL、npm 发布和跨平台凭据验收按独立门禁执行。
68
+
69
+ ## 完全离线交付(Stage 6A)
70
+
71
+ 离线交付制品由仓库内的纯 Node/npm 脚本生成,不会默认写入仓库。调用方必须先创建一个绝对路径的空目录,再执行:
72
+
73
+ ```bash
74
+ mkdir -p /tmp/zhihu-cli-release
75
+ node scripts/build-offline-release.mjs --output /tmp/zhihu-cli-release
76
+ node scripts/verify-offline-release.mjs --directory /tmp/zhihu-cli-release
77
+ ```
78
+
79
+ 制品目录严格包含版本化 `zhihu-ai-cli-<version>.tgz`、CycloneDX 1.5 `sbom.cdx.json`、`manifest.json`、LF 格式的 `SHA256SUMS` 和可单独复制运行的 `verify-release.mjs`。构建拒绝相对路径、根目录、符号链接和非空目录;失败时不会覆盖调用方目录。Manifest 固定声明 `p1-readonly`、Node.js `>=22.0.0`、Integration Protocol `1.0`、`/api/zhihu/integrations/v1` 以及 macOS ARM/x64 和 Linux x64 目标,不宣称 Windows。
80
+
81
+ Manifest provenance 的 `dirty_scope` 固定为 `packages/zhihu-ai-cli`;`dirty` 只表示 CLI package source scope 是否存在已跟踪改动或未跟踪源文件,不表示整个 monorepo 是否干净。现有 `dist/`、`node_modules/` 和 `releases/` 忽略规则继续排除生成输出、依赖缓存和交付制品;没有 Git 或无法解析 package scope 状态时构建会 fail closed。
82
+
83
+ 离线构建输出目录固定包含五个文件:版本化 CLI `.tgz`、`sbom.cdx.json`、`manifest.json`、`SHA256SUMS` 和可单独运行的 `verify-release.mjs`。复制 verifier 到隔离环境后,可在不安装 npm 依赖的情况下校验五个文件的完整性、allowlist、SBOM 和 provenance;CLI `.tgz` 可使用 `npm install --global --ignore-scripts --offline` 安装。对无法连接服务的安装验收,bundled Skill 会先落地而 Profile 不写入,符合失败回滚契约。
84
+
85
+ 客户安装时可在无网络环境使用本地包(Linux 使用 Node.js >=22,凭据登录需要 `secret-tool`/Secret Service;macOS 使用 Keychain):
86
+
87
+ ```bash
88
+ npm install --global --prefix /your/prefix --ignore-scripts --offline ./zhihu-ai-cli-<version>.tgz
89
+ /your/prefix/bin/zhihu --version
90
+ ```
91
+
92
+ 离线制品与本地校验覆盖 Linux x86_64 禁网场景;真实 OAuth、客户智护 URL、npm 发布、跨平台凭据和客户环境联调需要对应环境与发布门禁。
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: zhihu-aiops
3
+ description: 通过智护 Pilot CLI 对登录状态、能力、数字员工、资产、指标、告警、事件诊断和巡检执行安全的只读运维分析。当用户需要查询智护运行状态或解释告警时使用;目标不明确时先询问,不猜测身份或资源。
4
+ ---
5
+
6
+ # 智护 AI 运维
7
+
8
+ 本 Skill 只把明确的结构化查询交给已安装的 `zhihu` CLI,并依据单一 JSON 结果给出分析。先确认用户要看的租户、资产、告警、员工或时间范围,再选择一个最小查询;多个查询之间保留用户给出的精确 ID。
9
+
10
+ ## 工作流程
11
+
12
+ 1. 确认目标和范围。缺少资产 ID、告警 ID、员工 ID、模型或时间窗时提问;不要用名称猜测唯一对象。
13
+ 2. 只调用 `zhihu ... --json`,每次调用都等待完整 JSON envelope。不要把诊断参数改写成 URL、查询表达式或任意参数。
14
+ 3. 先查认证/能力,再查业务数据;认证失败时仅提示用户运行 `zhihu auth login`,不要要求用户粘贴任何凭据。
15
+ 4. 只依据 `ok`、稳定错误码和 `data` 字段回答。字段缺失、对象不唯一或结果过期时明确说明并停止推断。
16
+ 5. 对告警或巡检结论引用返回的证据、范围和时间窗;建议动作必须保持在只读排查范围内。
17
+ 6. 回复语言跟随当前用户语言;命令和稳定错误码保持原样,解释、确认和结论使用用户选择的中文或英文。
18
+
19
+ ## 可用查询
20
+
21
+ - `zhihu auth status --json`
22
+ - `zhihu capabilities list --json`
23
+ - `zhihu employees list --json`
24
+ - `zhihu assets list --json` 或 `zhihu assets get --id <id> --json`
25
+ - `zhihu metrics query --asset <id|ip> --metric <online|cpu|memory|load|disk|io|snmp> --window <5m..24h> --json`
26
+ - `zhihu alarms list --status <all|firing|resolved> --json` 或 `zhihu alarms get --id <id> --json`
27
+ - `zhihu incidents diagnose --alarm-id <id> --json`
28
+ - `zhihu inspections run [--model <name|code|id>] [--window <5m..24h>] --json`
29
+
30
+ 筛选、分页、模型和时间窗必须使用 CLI 已声明的取值;不要自行拼接未列出的选项。员工对话只有在用户明确给出员工 ID 和问题时才可使用 `zhihu employees chat <employee-id> --message <text> --json`,不得把对话当作执行变更的入口。
31
+
32
+ ## 输出和安全边界
33
+
34
+ - 只展示 CLI envelope 中必要的非敏感字段;不要复述认证材料、配置内容、环境变量或本机凭据。
35
+ - 只读命令不等于服务端一定有数据。遇到 `AUTH_RELOGIN_REQUIRED`、`FORBIDDEN`、`CAPABILITY_NOT_AVAILABLE`、`ASSET_NOT_FOUND`、`ALARM_NOT_FOUND`、`DATA_SOURCE_UNAVAILABLE`、`TIMEOUT` 或 `PROTOCOL_ERROR`,说明稳定错误码和下一步,不编造结果。
36
+ - 不直接访问智护服务、监控服务或容器;不执行额外命令,不访问本机配置/凭据存储,不联系外部地址。
37
+ - 本 Skill 不提供 MCP、变更、部署或公网包发布能力;`zhihu install codex` 只安装本地 Skill 文件。
38
+
39
+ 详细命令字段见 [references/commands.md](references/commands.md),错误处理见 [references/errors.md](references/errors.md),边界规则见 [references/safety.md](references/safety.md)。
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "智护 AI 运维"
3
+ short_description: "通过智护 CLI 安全执行只读运维分析能力(只读安全)"
4
+ default_prompt: "Use $zhihu-aiops to inspect an operational issue through the Zhihu Pilot CLI."
@@ -0,0 +1,33 @@
1
+ # CLI 命令参考
2
+
3
+ 所有调用都必须以 `--json` 结束或等价地启用 JSON 模式;只解析一个完整 envelope:成功看 `ok: true` 与 `data`,失败看 `ok: false`、`error.code`、`error.retryable`。
4
+
5
+ ## 会话与能力
6
+
7
+ | 目的 | 命令 |
8
+ | --- | --- |
9
+ | 查看当前登录状态 | `zhihu auth status --json` |
10
+ | 查看能力范围 | `zhihu capabilities list --json` |
11
+ | 查看可用数字员工 | `zhihu employees list --json` |
12
+ | 发起只读员工问答 | `zhihu employees chat <employee-id> --message <text> --json` |
13
+
14
+ 问答前必须由用户确认员工 ID;`--message` 只描述查询,不描述变更。
15
+
16
+ ## 资产、指标与告警
17
+
18
+ | 目的 | 命令 |
19
+ | --- | --- |
20
+ | 查资产 | `zhihu assets list [--keyword <text>] [--model <name|code|id>] --json` |
21
+ | 取资产 | `zhihu assets get --id <id> --json` |
22
+ | 查指标 | `zhihu metrics query --asset <id|ip> --metric <allowed-name> --window <5m..24h> --json` |
23
+ | 列告警 | `zhihu alarms list --status <all|firing|resolved> --json` |
24
+ | 取告警 | `zhihu alarms get --id <id> --json` |
25
+
26
+ 资产和告警 ID 必须来自用户或此前 CLI 返回;IP、指标和窗口必须满足 CLI 的白名单与上限。
27
+
28
+ ## 诊断与巡检
29
+
30
+ - 指定告警分析:`zhihu incidents diagnose --alarm-id <id> --json`
31
+ - 只读巡检摘要:`zhihu inspections run [--model <name|code|id>] [--window <5m..24h>] --json`
32
+
33
+ 诊断和巡检结果是保守的结构化摘要。证据不足时报告不足,不补全设备、租户、权限或因果关系。
@@ -0,0 +1,14 @@
1
+ # 稳定错误处理
2
+
3
+ 只依据 JSON envelope 的稳定 `error.code` 和 `error.retryable`。不要读取原始响应正文,也不要把错误中的路径、地址或调试细节扩散给用户。
4
+
5
+ 常见处理:
6
+
7
+ - `AUTH_RELOGIN_REQUIRED`:提示用户运行 `zhihu auth login`,然后重试原查询。
8
+ - `FORBIDDEN`、`CAPABILITY_NOT_AVAILABLE`:说明当前账号没有所需范围,请用户联系管理员;不要尝试其他身份。
9
+ - `EMPLOYEE_NOT_FOUND`、`ASSET_NOT_FOUND`、`ALARM_NOT_FOUND`:让用户确认精确 ID;不要从相似名称猜测。
10
+ - `INCIDENT_ASSET_IDENTITY_UNAVAILABLE`、`ASSET_METRIC_IDENTITY_UNAVAILABLE`、`METRIC_DATA_NOT_FOUND`:报告目标无法安全关联或没有数据。
11
+ - `DATA_SOURCE_UNAVAILABLE`、`TIMEOUT`、`NETWORK_ERROR`、`RATE_LIMITED`:保留原查询边界,按 `retryable` 决定是否建议稍后重试。
12
+ - `INVALID_ARGUMENT`、`PROTOCOL_ERROR`、`VERSION_INCOMPATIBLE`:说明 CLI 参数或版本问题,先停止分析。
13
+
14
+ 认证、权限、数据源和协议错误都不能被改写成“正常”或“已修复”。
@@ -0,0 +1,9 @@
1
+ # 安全边界
2
+
3
+ - 唯一入口是本机 `zhihu` CLI 的 JSON 命令。不要从本机配置、环境变量、凭据服务或日志读取认证材料。
4
+ - 不要直接访问智护 API、监控后端、容器地址或任意网络地址;不要改写请求地址、查询表达式、标签、租户或权限。
5
+ - 不执行额外系统命令,不操作主机、文件、进程、网络设备或部署;不要把 CLI 结果发送给外部服务。
6
+ - 只读范围包括认证状态、能力、数字员工、资产、指标、告警、指定告警诊断和巡检。任何创建、删除、更新、重启、发布或变更请求都应拒绝并说明当前能力限制。
7
+ - 用户没有给出唯一目标时先询问:资产 ID、告警 ID、员工 ID、模型、指标、窗口和语言都不能凭空补全。
8
+ - 结果只保留回答所需的字段。不要输出本机路径、内部地址、完整请求、会话材料或未脱敏正文。
9
+ - 这是本地 Skill,不宣称 MCP、变更能力、在线安装或公网包已经可用。
package/bin/zhihu.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../dist/cli.js";
4
+
5
+ process.exitCode = await main();
@@ -0,0 +1,204 @@
1
+ import { openSystemBrowser, validAuthorizationUrl } from "./browser.js";
2
+ import { HttpError } from "./http.js";
3
+ import { createPkcePair } from "./pkce.js";
4
+ import { withProfileLock } from "./profile-lock.js";
5
+ import { CliAuthError, TokenManager, decodeClaims, mapFailure } from "./token-manager.js";
6
+ function sleep(milliseconds) {
7
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
8
+ }
9
+ // ECMAScript Date values are only representable within this inclusive range.
10
+ // JWT NumericDate values outside it must not be allowed to make presentation
11
+ // fail after the refresh credential has already been persisted.
12
+ const MAX_DATE_MILLISECONDS = 8_640_000_000_000_000;
13
+ const DEFAULT_EXPIRES_IN_SECONDS = 900;
14
+ function validDateMilliseconds(value) {
15
+ return Number.isFinite(value) && value >= 0 && value <= MAX_DATE_MILLISECONDS;
16
+ }
17
+ function safeExpiresAt(now, expiresIn, claims) {
18
+ const fallbackSeconds = typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0
19
+ ? expiresIn
20
+ : DEFAULT_EXPIRES_IN_SECONDS;
21
+ const baseNow = typeof now === "number" && validDateMilliseconds(now) ? now : Date.now();
22
+ const fallbackMilliseconds = baseNow + fallbackSeconds * 1000;
23
+ const safeFallbackMilliseconds = validDateMilliseconds(fallbackMilliseconds)
24
+ ? fallbackMilliseconds
25
+ : Math.min(MAX_DATE_MILLISECONDS, Math.max(0, Date.now() + DEFAULT_EXPIRES_IN_SECONDS * 1000));
26
+ const exp = claims.exp;
27
+ const expMilliseconds = typeof exp === "number" && Number.isFinite(exp) && exp > 0
28
+ ? exp * 1000
29
+ : NaN;
30
+ const milliseconds = validDateMilliseconds(expMilliseconds) ? expMilliseconds : safeFallbackMilliseconds;
31
+ try {
32
+ return new Date(milliseconds).toISOString();
33
+ }
34
+ catch {
35
+ // The fallback above is bounded, but keep presentation total even if a
36
+ // host supplies an exotic Date implementation in an embedding process.
37
+ return new Date(0).toISOString();
38
+ }
39
+ }
40
+ function displayFromToken(profile, token, expiresIn, now, browserOpened) {
41
+ if (typeof token !== "string" || token.length === 0) {
42
+ throw new CliAuthError("PROTOCOL_ERROR", "The server returned an invalid login token", { exitCode: 8 });
43
+ }
44
+ let claims;
45
+ try {
46
+ claims = decodeClaims(token);
47
+ }
48
+ catch {
49
+ // Token claim parsing is presentation-only. A malformed payload must not
50
+ // turn a successful login into an unclassified/network failure.
51
+ claims = {};
52
+ }
53
+ const user = typeof claims.zhihu_user_id === "string" ? claims.zhihu_user_id : undefined;
54
+ const tenant = typeof claims.tenant_id === "string" ? claims.tenant_id : undefined;
55
+ const scopes = Array.isArray(claims.scopes)
56
+ ? claims.scopes.filter((item) => typeof item === "string")
57
+ : typeof claims.scope === "string" ? claims.scope.split(" ").filter(Boolean) : [];
58
+ return {
59
+ logged_in: true,
60
+ profile,
61
+ ...(user ? { user_id: user } : {}),
62
+ ...(tenant !== undefined ? { tenant_id: tenant } : {}),
63
+ scopes,
64
+ access_expires_at: safeExpiresAt(now, expiresIn, claims),
65
+ ...(browserOpened === undefined ? {} : { browser_opened: browserOpened }),
66
+ };
67
+ }
68
+ export class AuthService {
69
+ tokenManager;
70
+ client;
71
+ credentials;
72
+ profile;
73
+ browser;
74
+ onBrowserFailure;
75
+ wait;
76
+ now;
77
+ initialDelay;
78
+ maxDelay;
79
+ constructor(client, credentials, profile, options = {}) {
80
+ this.client = client;
81
+ this.credentials = credentials;
82
+ this.profile = profile;
83
+ this.browser = options.browser ?? openSystemBrowser;
84
+ this.onBrowserFailure = options.onBrowserFailure;
85
+ this.wait = options.sleep ?? sleep;
86
+ this.now = options.now ?? Date.now;
87
+ this.initialDelay = options.pollInitialDelayMs ?? 250;
88
+ this.maxDelay = options.pollMaxDelayMs ?? 2000;
89
+ this.tokenManager = new TokenManager(client, credentials, profile);
90
+ }
91
+ async login() {
92
+ try {
93
+ return await withProfileLock(this.profile, async () => {
94
+ let existing;
95
+ try {
96
+ existing = await this.credentials.get();
97
+ }
98
+ catch (error) {
99
+ throw mapFailure(error);
100
+ }
101
+ if (existing) {
102
+ throw new CliAuthError("AUTH_ALREADY_LOGGED_IN", "This profile is already logged in; run zhihu auth logout first.", { exitCode: 3 });
103
+ }
104
+ const pkce = createPkcePair();
105
+ let started;
106
+ try {
107
+ started = await this.client.start(pkce.challenge);
108
+ }
109
+ catch (error) {
110
+ throw mapFailure(error);
111
+ }
112
+ if (!validAuthorizationUrl(started.authorization_url)) {
113
+ throw new CliAuthError("PROTOCOL_ERROR", "The server returned an unsafe authorization URL", { exitCode: 8 });
114
+ }
115
+ const browserOpened = await this.browser(started.authorization_url);
116
+ if (!browserOpened)
117
+ this.onBrowserFailure?.(started.authorization_url);
118
+ let delay = this.initialDelay;
119
+ const deadline = this.now() + started.expires_in * 1000;
120
+ while (this.now() < deadline) {
121
+ let outcome;
122
+ try {
123
+ outcome = await this.client.poll(started.transaction_id, started.polling_secret, pkce.verifier);
124
+ }
125
+ catch (error) {
126
+ if (error instanceof HttpError && error.retryable && this.now() < deadline) {
127
+ await this.wait(Math.min(delay, Math.max(1, deadline - this.now())));
128
+ delay = Math.min(this.maxDelay, Math.ceil(delay * 1.7));
129
+ continue;
130
+ }
131
+ throw mapFailure(error);
132
+ }
133
+ if (outcome.status === "pending" || outcome.status === "callback_processing") {
134
+ await this.wait(Math.min(delay, Math.max(1, deadline - this.now())));
135
+ delay = Math.min(this.maxDelay, Math.ceil(delay * 1.7));
136
+ continue;
137
+ }
138
+ if (outcome.status === "failed") {
139
+ throw new CliAuthError(outcome.error_code || "AUTH_RELOGIN_REQUIRED", "Browser authorization was not completed; run zhihu auth login again.", { exitCode: 3 });
140
+ }
141
+ if (outcome.status !== "completed") {
142
+ throw new CliAuthError("PROTOCOL_ERROR", "The server returned an invalid login response", { exitCode: 8 });
143
+ }
144
+ const accessToken = outcome.access_token;
145
+ const refreshCredential = outcome.refresh_credential;
146
+ if (typeof accessToken !== "string" || accessToken.length === 0
147
+ || typeof refreshCredential !== "string" || refreshCredential.length === 0) {
148
+ await this.compensateCompletedLogin(refreshCredential);
149
+ throw new CliAuthError("PROTOCOL_ERROR", "The server returned an invalid login response", { exitCode: 8 });
150
+ }
151
+ try {
152
+ await this.credentials.set(refreshCredential);
153
+ }
154
+ catch {
155
+ await this.compensateCompletedLogin(refreshCredential);
156
+ throw new CliAuthError("AUTH_RELOGIN_REQUIRED", "Credential storage failed; run zhihu auth login again.", { exitCode: 3 });
157
+ }
158
+ try {
159
+ return displayFromToken(this.profile, accessToken, outcome.expires_in ?? DEFAULT_EXPIRES_IN_SECONDS, this.now(), browserOpened);
160
+ }
161
+ catch (error) {
162
+ await this.compensateCompletedLogin(refreshCredential);
163
+ if (error instanceof CliAuthError)
164
+ throw error;
165
+ // Presentation errors are a server/protocol problem, never a
166
+ // transport failure. Do not expose arbitrary error text because it
167
+ // could contain a token supplied by an embedding caller.
168
+ throw new CliAuthError("PROTOCOL_ERROR", "The server returned an invalid login token", { exitCode: 8 });
169
+ }
170
+ }
171
+ throw new CliAuthError("AUTH_LOGIN_TIMEOUT", "Login timed out; run zhihu auth login again.", { retryable: true, exitCode: 3 });
172
+ });
173
+ }
174
+ catch (error) {
175
+ throw mapFailure(error);
176
+ }
177
+ }
178
+ async status() {
179
+ const token = await this.tokenManager.refreshNow();
180
+ const info = this.tokenManager.accessInfo;
181
+ return displayFromToken(this.profile, token, info ? Math.max(1, Math.round((info.expiresAt - this.now()) / 1000)) : 900, this.now());
182
+ }
183
+ async logout() {
184
+ const state = await this.tokenManager.logout();
185
+ return { logged_out: true, profile: this.profile, state };
186
+ }
187
+ async compensateCompletedLogin(refreshCredential) {
188
+ if (typeof refreshCredential === "string" && refreshCredential.length > 0) {
189
+ try {
190
+ await this.client.logout(refreshCredential);
191
+ }
192
+ catch {
193
+ // Best effort only. The primary stable auth/protocol error is safer
194
+ // than exposing a provider or credential-store diagnostic.
195
+ }
196
+ }
197
+ try {
198
+ await this.credentials.delete();
199
+ }
200
+ catch {
201
+ // Keep the primary error stable even when local cleanup is unavailable.
202
+ }
203
+ }
204
+ }
@@ -0,0 +1,49 @@
1
+ import { spawn } from "node:child_process";
2
+ export function validAuthorizationUrl(value) {
3
+ try {
4
+ const parsed = new URL(value);
5
+ const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]" || parsed.hostname === "::1";
6
+ return /^https?:$/.test(parsed.protocol)
7
+ && (parsed.protocol === "https:" || loopback)
8
+ && !parsed.username
9
+ && !parsed.password
10
+ && !/[\u0000-\u001f\u007f]/.test(value);
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ /** Launch a browser without a shell; the URL is never logged by this helper. */
17
+ export const openSystemBrowser = (authorizationUrl) => new Promise((resolve) => {
18
+ if (!validAuthorizationUrl(authorizationUrl)) {
19
+ resolve(false);
20
+ return;
21
+ }
22
+ const command = process.platform === "darwin" ? "/usr/bin/open" : process.platform === "linux" ? "/usr/bin/xdg-open" : undefined;
23
+ if (!command) {
24
+ resolve(false);
25
+ return;
26
+ }
27
+ let child;
28
+ try {
29
+ child = spawn(command, [authorizationUrl], { shell: false, stdio: ["ignore", "ignore", "ignore"] });
30
+ }
31
+ catch {
32
+ resolve(false);
33
+ return;
34
+ }
35
+ let settled = false;
36
+ const timer = setTimeout(() => {
37
+ child.kill();
38
+ finish(false);
39
+ }, 5000);
40
+ const finish = (ok) => {
41
+ if (settled)
42
+ return;
43
+ settled = true;
44
+ clearTimeout(timer);
45
+ resolve(ok);
46
+ };
47
+ child.once("error", () => finish(false));
48
+ child.once("close", (code) => finish(code === 0));
49
+ });