hiwork-core 0.0.2 → 0.0.6

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 CHANGED
@@ -21,15 +21,20 @@ login gate / settings card(client)──credentials.set / settings.mutate─
21
21
 
22
22
  ### 多协议路由(协议随模型走)
23
23
 
24
- 网关按「入站协议 + 模型」匹配模型映射(`GET /api/me/key` 的 `model_mapping`),而 pi-ai 的 `api` 是**路由级**的 —— 因此插件把预算内模型按映射拆到协议路由,每路由一个协议、baseURL 按协议取对:
24
+ 网关按「入站协议 + 模型」匹配模型映射(`GET /api/me/key` 的 `model_mapping`),而 pi-ai 的 `api` 是**路由级**的 —— 因此插件把预算内模型按协议拆到路由,每路由一个协议、baseURL 按协议取对。路由在模型选择器里的分组标题用「线路C/M/R」,后缀即该路由讲的协议首字母:
25
25
 
26
- | 路由 | 协议 | baseURL 后缀 | 说明 |
27
- |---|---|---|---|
28
- | `providers.ai-router` | `openai-responses` | `…/v1`(SDK 追加 `/responses`) | 主路由(兼容 `agent-default-model: provider: ai-router`),默认请求走 `/v1/responses` |
29
- | `providers.ai-router-anthropic` | `anthropic-messages` | 裸 base | Anthropic SDK 自带 `v1/` 前缀 |
26
+ | 路由 | 展示名 | 协议 | baseURL 后缀 | 说明 |
27
+ |---|---|---|---|---|
28
+ | `providers.ai-router-chat` | 线路C | `openai-completions` | `…/v1`(SDK 追加 `/chat/completions`) | chat/completions,兜底协议 |
29
+ | `providers.ai-router-anthropic` | 线路M | `anthropic-messages` | 裸 base | Anthropic SDK 自带 `v1/` 前缀 |
30
+ | `providers.ai-router` | 线路R | `openai-responses` | `…/v1`(SDK 追加 `/responses`) | key 与协议保持不变,兼容 `agent-default-model: provider: ai-router` |
30
31
 
31
- - 归属规则:精确 EqualFold 优先、`*`/`?` 通配(裸 `*` 兜底不参与归属);OpenAI 兼容映射(chat/responses)统一落主路由(responses),anthropic 映射落 anthropic 路由;无映射命中的模型落主路由(默认 responses)。
32
- - 好处:DSH 里切换模型自动落到对应协议的路由;默认 DeepSeek 模型经 `https://aigateway.hivery.cn/v1/responses`;anthropic 系(如 k3/k3-256k/kimi-for-coding)不再 404(`/v1/v1/messages`)也不用手动整路切协议。
32
+ - 归属规则:精确 EqualFold 优先、`*`/`?` 通配(裸 `*` 兜底不参与归属);模型进入**每一条**映射命中的线路 —— 按协议分组、不做唯一归属,同一模型可以同时出现在多条线路里(例如同时声明 responses 与 anthropic 映射的模型,线路R 和线路M 都列出它),从哪条线路选它,请求就走那条线路的协议;无任何映射命中的模型落兜底线路C(网关的裸 `*` 兜底映射通常声明在 chat/completions 下,用 responses 入站反而匹配不到映射)。
33
+ - 好处:DSH 里切换模型自动落到对应协议的路由;responses 系模型经 `https://aigateway.hivery.cn/v1/responses`;anthropic 系(如 k3/k3-256k/kimi-for-coding)不再 404(`/v1/v1/messages`)也不用手动整路切协议;只声明 chat/completions 映射的模型落线路C,不再被塞进 responses 入站。
34
+ - 空线路不写入 profile(写入侧同时清掉该路由,旧版独立的 `ai-router-responses` 路由也会被清理)。
35
+ - **官方 DeepSeek 提供方在本 bundle 内停用**:`cordis.patch.yml` 用 `- id: llm-deepseek` + `disabled: true` 关掉 `deepseek-official`。官方适配器讲 DeepSeek 私有协议(请求体带 `thinking`、响应读 `reasoning_content`、图片走 Files API),网关只实现 OpenAI/Anthropic 兼容协议,改 baseURL 也对接不上,留着只会让模型选择器多出一个必然失败的分组。需要保留官方的部署,在 profile 的 `cordis.patch.yml` 里写同 id + `disabled: false` 覆盖(profile 层优先级高于 bundle 层)。
36
+ - **官方 web 搜索同样停用**:`web-search-deepseek` 也走 `DEEPSEEK_API_KEY`(企业用户没有),故 `- id: web-search-deepseek` + `disabled: true` 停用提供方(设置页「Web 搜索」卡片随命名空间消失),并把 `tool-web` 的 `search` 关掉,只保留不依赖凭据的 `web_fetch`。
37
+ - **默认模型校正**:`agent-default-model` 的组合默认值指向官方提供方(`deepseek-official/deepseek-v4-flash`)。供给成功后(登录/刷新)校正一次:当前选择仍可路由(本插件线路内查表,或其它已注册提供方 + 模型仍在列)就保持不变,否则落到兜底线路C 的第一个模型,避免新会话第一次请求就 `UNKNOWN_MODEL`。只写用户层、不写 composition,用户自己选的模型只要仍可用就不会被覆盖。
33
38
 
34
39
  ## 登录门禁
35
40
 
@@ -103,6 +108,9 @@ ctx.inject(['hiworkFeatureCenter'], (scope) => {
103
108
  其余项按 order 排序。`icon` 是内联 SVG 字符串;缺省用兜底图标,保证侧栏折叠成
104
109
  56px 图标栏时每个入口仍有可辨识图形(折叠态由宿主加在侧栏根上的 `*_collapsed`
105
110
  类驱动纯 CSS:隐藏文字、图标放大到 18px,与原生控件同形)。
111
+ 折叠态悬停 500ms 弹出提示气泡,黑底白字、圆角与字号逐项对齐框架 `ui-primitives`
112
+ 的 `Tooltip`(`--dsw-alias-tooltip-bg`),取代浏览器自带的浅色 `title` 提示;
113
+ 展开态文字已可见,与原生控件一致(`Tooltip disabled={wide}`)不出气泡。
106
114
  找不到原生按钮时不抛错、不隐藏任何入口,菜单只列 feature(安全降级)。
107
115
  - **中央区 takeover**:挂一个 `[data-hiwork-feature-root]` React root,用
108
116
  `html[data-hiwork-active-page]` 上的纯 CSS 规则隐藏原生会话子树(保持挂载、保留状态);
@@ -122,6 +130,7 @@ ctx.inject(['hiworkFeatureCenter'], (scope) => {
122
130
  - [src/client/feature-center.ts](src/client/feature-center.ts):侧栏菜单注入 + 中央区 takeover。
123
131
  - [src/client/contracts.ts](src/client/contracts.ts):业务插件可见的 client 面与服务名。
124
132
  - [src/orchestration.ts](src/orchestration.ts):登录后模型供给编排(拉 Key、写路由、热重载)。
133
+ - [src/default-model.ts](src/default-model.ts):供给成功后校正 `agent-default-model`(默认模型落点)。
125
134
  - [src/client/login-gate.tsx](src/client/login-gate.tsx):全屏登录门禁页。
126
135
  - [src/client/section.tsx](src/client/section.tsx):设置页「AI Router 网关」卡片。
127
136
  - [src/client/user-badge.tsx](src/client/user-badge.tsx):已登录身份与 KEY 选择器。
package/cordis.patch.yml CHANGED
@@ -4,6 +4,27 @@
4
4
  # 注意:不在这里挂 llm-pi-ai —— 标准 profile 的 dsh-base 已挂载它(dormant),
5
5
  # 重复 insert 同一 id 会在 loader 建 EntryGroup 时报 "duplicate loader entry id"。
6
6
  # hiwork-core 写入的正是 llm-pi-ai 的配置段;若部署缺 llm-pi-ai,写配置会 fail loud。
7
+
8
+ # 停用官方 DeepSeek 提供方(deepseek-official):本产品只供给网关模型,
9
+ # 官方适配器讲的是 DeepSeek 私有协议(请求体带 thinking、响应读 reasoning_content、
10
+ # 图片走 Files API),网关只实现 OpenAI/Anthropic 兼容协议,改 baseURL 也对接不上,
11
+ # 留在模型选择器里只会多出一个必然失败的分组。
12
+ # 该条目由 dsh-base 插入,本 bundle 的 patch 层在它之后,所以按 id 覆盖生效。
13
+ # 需要保留官方的部署在 profile 的 cordis.patch.yml 里写
14
+ # `- id: llm-deepseek` + `disabled: false` 即可(profile 层优先级高于 bundle 层)。
15
+ - id: llm-deepseek
16
+ disabled: true
17
+
18
+ # 同上:官方 web 搜索(web-search-deepseek)也走 DEEPSEEK_API_KEY,企业用户没有该 Key,
19
+ # 调用必然失败。停用提供方(设置页那张「Web 搜索」卡片随之消失)+ 关掉 web_search 工具,
20
+ # 只保留走 http 的 web_fetch(抓取网页不依赖任何提供方凭据)。
21
+ - id: web-search-deepseek
22
+ disabled: true
23
+
24
+ - id: tool-web
25
+ config:
26
+ search: false
27
+
7
28
  - insert:
8
29
  - id: hiwork-core
9
30
  name: 'hiwork-core'
@@ -1,4 +1,20 @@
1
1
  import { PKG_VERSION } from "./version.js";
2
+ /** 浏览器授权登录(PKCE)端点:域名一律来自 baseURL,不硬编码。 */
3
+ const AUTH_AUTHORIZE_PATH = '/api/auth/authorize';
4
+ const AUTH_TOKEN_PATH = '/api/auth/token';
5
+ const AUTH_SESSIONS_PATH = '/api/auth/sessions';
6
+ /** 自助用量与成本端点(均挂在网关 /api/me 会话组下,鉴权由 SessionAuth 负责)。 */
7
+ const ME_ANALYTICS_SUMMARY_PATH = '/api/me/analytics/summary';
8
+ const ME_ANALYTICS_TRENDS_PATH = '/api/me/analytics/trends';
9
+ const ME_ANALYTICS_MODEL_TRENDS_PATH = '/api/me/analytics/model-trends';
10
+ const ME_KEY_RECHARGES_PATH = '/api/me/key/recharges';
11
+ const ME_COST_ESTIMATE_PATH = '/api/me/cost-estimate';
12
+ /** 每日一句端点:当日条目(东八区取模轮换)与换一句(排除当前条目的随机)。 */
13
+ const ME_DAILY_QUOTE_PATH = '/api/me/daily-quote';
14
+ const ME_DAILY_QUOTE_RANDOM_PATH = '/api/me/daily-quote/random';
15
+ /** 授权请求默认值:桌面端 client_id 与自定义协议回调地址(网关侧精确匹配)。 */
16
+ const DEFAULT_CLIENT_ID = 'hiwork-desktop';
17
+ const DEFAULT_REDIRECT_URI = 'hiwork://auth/callback';
2
18
  /** 网关错误:携带稳定 code,供编排层映射。 */
3
19
  export class GatewayError extends Error {
4
20
  code;
@@ -10,6 +26,26 @@ export class GatewayError extends Error {
10
26
  this.name = 'GatewayError';
11
27
  }
12
28
  }
29
+ /**
30
+ * 构造浏览器授权页 URL(GET /api/auth/authorize)。
31
+ * 全部参数按 encodeURIComponent 转义——`redirect_uri` 的 `hiwork://` 必须编码成
32
+ * `hiwork%3A%2F%2Fauth%2Fcallback`,否则查询串会被网关按 `//` 截断。
33
+ */
34
+ export function buildAuthorizeURL(opts) {
35
+ const params = [
36
+ ['client_id', opts.clientId ?? DEFAULT_CLIENT_ID],
37
+ ['redirect_uri', opts.redirectUri ?? DEFAULT_REDIRECT_URI],
38
+ ['code_challenge', opts.codeChallenge],
39
+ ['code_challenge_method', 'S256'],
40
+ ['state', opts.state],
41
+ ['device_id', opts.deviceId],
42
+ ['device_name', opts.deviceName],
43
+ ['platform', opts.platform],
44
+ ['app_version', opts.appVersion],
45
+ ];
46
+ const query = params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&');
47
+ return `${opts.baseURL}${AUTH_AUTHORIZE_PATH}?${query}`;
48
+ }
13
49
  /** fetch + 解析 ai-router HTTP 面。零 dsh 依赖。 */
14
50
  export class AiRouterClient {
15
51
  baseURL;
@@ -18,8 +54,12 @@ export class AiRouterClient {
18
54
  this.baseURL = baseURL;
19
55
  this.timeoutMs = timeoutMs;
20
56
  }
21
- async login(username, password) {
22
- const res = await this.request('/api/admin/auth/login', { method: 'POST', body: { username, password } });
57
+ async login(username, password, device) {
58
+ const res = await this.request('/api/admin/auth/login', {
59
+ method: 'POST',
60
+ body: { username, password },
61
+ headers: deviceHeaders(device),
62
+ });
23
63
  const sid = extractSessionId(res.headers);
24
64
  if (res.status === 429)
25
65
  throw new GatewayError('RATE_LIMITED', '登录尝试过于频繁,请稍后再试', 429);
@@ -34,6 +74,55 @@ export class AiRouterClient {
34
74
  const user = await res.json();
35
75
  return { sessionCookie: `session_id=${sid}`, user };
36
76
  }
77
+ /**
78
+ * POST /api/auth/token:用一次性 code + PKCE verifier 换取会话。
79
+ * 返回 `session_id=<sid>`(与 login 同形状,编排层可直接复用),user 原样返回。
80
+ * 任何日志/异常信息都不得包含 code / code_verifier / session_id 明文。
81
+ */
82
+ async exchangeCode(input) {
83
+ const res = await this.requestOrUnreachable(AUTH_TOKEN_PATH, {
84
+ method: 'POST',
85
+ body: { code: input.code, code_verifier: input.codeVerifier, device_id: input.deviceId },
86
+ });
87
+ if (res.status === 400) {
88
+ // 仅 body 明确为 invalid_grant 才是确定性授权失败;其余 400 按网关异常处理。
89
+ const payload = await readJson(res);
90
+ if (isInvalidGrant(payload))
91
+ throw new GatewayError('AUTH_FAILED', '授权失败,请重新登录', 400);
92
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
93
+ }
94
+ if (!res.ok)
95
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
96
+ // 用 readJson 而非 res.json():JSON.parse 的报错会把响应片段带进异常信息,
97
+ // 可能把一次性 code / session_id 泄漏到日志。
98
+ const data = await readJson(res);
99
+ if (data === undefined || data.session_id === undefined || data.session_id === '') {
100
+ throw new GatewayError('GATEWAY_UNREACHABLE', '网关兑换成功但未返回会话标识', res.status);
101
+ }
102
+ return { sessionCookie: `session_id=${data.session_id}`, user: data.user };
103
+ }
104
+ /** GET /api/auth/sessions:当前账号的登录设备列表。 */
105
+ async listSessions(sessionCookie) {
106
+ const res = await this.requestOrUnreachable(AUTH_SESSIONS_PATH, { method: 'GET', sessionCookie });
107
+ if (res.status === 401)
108
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
109
+ if (!res.ok)
110
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
111
+ return await res.json();
112
+ }
113
+ /** DELETE /api/auth/sessions/:id:远程吊销指定会话(204 成功;404 表示已不存在)。 */
114
+ async revokeSession(sessionCookie, sessionId) {
115
+ const res = await this.requestOrUnreachable(`${AUTH_SESSIONS_PATH}/${encodeURIComponent(sessionId)}`, {
116
+ method: 'DELETE',
117
+ sessionCookie,
118
+ });
119
+ if (res.status === 401)
120
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
121
+ if (res.status === 404)
122
+ throw new GatewayError('NOT_FOUND', '会话不存在或已被吊销', 404);
123
+ if (!res.ok)
124
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
125
+ }
37
126
  async getMyKey(sessionCookie, keyId) {
38
127
  const qs = keyId === undefined ? '' : `?key_id=${encodeURIComponent(keyId)}`;
39
128
  const res = await this.request(`/api/me/key${qs}`, { method: 'GET', sessionCookie });
@@ -69,12 +158,94 @@ export class AiRouterClient {
69
158
  // 登出 best-effort:网络失败不阻塞本地清理。
70
159
  }
71
160
  }
161
+ /** GET /api/me/analytics/summary:按模型聚合的用量汇总。 */
162
+ async getMySummary(sessionCookie, keyId, range) {
163
+ const query = meQuery([['key_id', keyId], ['range', range]]);
164
+ return await this.getMeJson(`${ME_ANALYTICS_SUMMARY_PATH}${query}`, sessionCookie);
165
+ }
166
+ /** GET /api/me/analytics/trends:token/费用走势。 */
167
+ async getMyTrends(sessionCookie, keyId, range) {
168
+ const query = meQuery([['key_id', keyId], ['range', range]]);
169
+ return await this.getMeJson(`${ME_ANALYTICS_TRENDS_PATH}${query}`, sessionCookie);
170
+ }
171
+ /** GET /api/me/analytics/model-trends:按模型拆分的走势。 */
172
+ async getMyModelTrends(sessionCookie, keyId, range) {
173
+ const query = meQuery([['key_id', keyId], ['range', range]]);
174
+ return await this.getMeJson(`${ME_ANALYTICS_MODEL_TRENDS_PATH}${query}`, sessionCookie);
175
+ }
176
+ /** GET /api/me/key/recharges:当前 Key 的充值台账(可按模型过滤)。 */
177
+ async getMyKeyRecharges(sessionCookie, keyId, modelName) {
178
+ const query = meQuery([['key_id', keyId], ['model_name', modelName]]);
179
+ return await this.getMeJson(`${ME_KEY_RECHARGES_PATH}${query}`, sessionCookie);
180
+ }
181
+ /** GET /api/me/daily-quote:当日条目(服务端东八区取模轮换,全网同一天同一条)。 */
182
+ async getMyDailyQuote(sessionCookie) {
183
+ return await this.getMeJson(ME_DAILY_QUOTE_PATH, sessionCookie);
184
+ }
185
+ /** GET /api/me/daily-quote/random:换一句(exclude 为当前条目 id;非法值由网关按缺省处理)。 */
186
+ async getMyDailyQuoteRandom(sessionCookie, excludeId) {
187
+ const query = meQuery([['exclude', excludeId === undefined ? undefined : String(excludeId)]]);
188
+ return await this.getMeJson(`${ME_DAILY_QUOTE_RANDOM_PATH}${query}`, sessionCookie);
189
+ }
190
+ /**
191
+ * POST /api/me/cost-estimate:网关权威计价。请求体里的四个 token 桶原样上报,
192
+ * `input_tokens` 不含 cache;任何折算与单价计算都在网关侧完成。
193
+ */
194
+ async estimateCost(sessionCookie, items) {
195
+ const res = await this.requestOrUnreachable(ME_COST_ESTIMATE_PATH, {
196
+ method: 'POST',
197
+ sessionCookie,
198
+ body: { items },
199
+ });
200
+ if (res.status === 401)
201
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
202
+ if (res.status === 403) {
203
+ throw new GatewayError('NO_KEY_ASSIGNED', '当前账号尚未被分配虚拟 KEY,请联系管理员分配 KEY 后再登录/刷新', 403);
204
+ }
205
+ // 400 既可能是非法 key_id,也可能是请求体越界(>50 项 / 负数 token),
206
+ // 故不按「KEY 失效」报错,统一按网关侧拒绝处理,调用方降级为不显示金额。
207
+ if (!res.ok)
208
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
209
+ return await parseMeJson(res);
210
+ }
211
+ /**
212
+ * /api/me 自助端点的 GET 公共路径:网络异常 → GATEWAY_UNREACHABLE,
213
+ * 401/403/400 映射为稳定 code,响应体非 JSON 时不把原文带进异常。
214
+ */
215
+ async getMeJson(path, sessionCookie) {
216
+ const res = await this.requestOrUnreachable(path, { method: 'GET', sessionCookie });
217
+ if (res.status === 401)
218
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
219
+ if (res.status === 403) {
220
+ throw new GatewayError('NO_KEY_ASSIGNED', '当前账号尚未被分配虚拟 KEY,请联系管理员分配 KEY 后再登录/刷新', 403);
221
+ }
222
+ if (res.status === 400) {
223
+ throw new GatewayError('KEY_INVALID', '所选 KEY 无效或已被删除,请重新选择 KEY 后刷新', 400);
224
+ }
225
+ if (!res.ok)
226
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
227
+ return await parseMeJson(res);
228
+ }
229
+ /**
230
+ * 浏览器授权相关端点专用:网络层异常(连接失败 / 超时 / DNS)统一映射为
231
+ * GATEWAY_UNREACHABLE,避免裸 fetch 错误漏到编排层。
232
+ * login 等既有方法不经过此处,行为保持不变。
233
+ */
234
+ async requestOrUnreachable(path, opts) {
235
+ try {
236
+ return await this.request(path, opts);
237
+ }
238
+ catch {
239
+ throw new GatewayError('GATEWAY_UNREACHABLE', '网关不可达,请检查网络后重试');
240
+ }
241
+ }
72
242
  async request(path, opts) {
73
243
  const headers = { 'user-agent': `hiwork-core/${PKG_VERSION}` };
74
244
  if (opts.body !== undefined)
75
245
  headers['content-type'] = 'application/json';
76
246
  if (opts.sessionCookie !== undefined)
77
247
  headers['cookie'] = opts.sessionCookie;
248
+ Object.assign(headers, opts.headers ?? {});
78
249
  const init = {
79
250
  method: opts.method,
80
251
  headers,
@@ -85,6 +256,57 @@ export class AiRouterClient {
85
256
  return fetch(`${this.baseURL}${path}`, init);
86
257
  }
87
258
  }
259
+ /**
260
+ * 请求头值清洗:HTTP 头只允许可见 ASCII(RFC 9110 field-value)。去掉控制字符
261
+ * (含 CR/LF,防头注入)与所有非 ASCII 字符——中文主机名(os.hostname() 或桌面端推来的
262
+ * 设备名)会让 `fetch`/`Headers` 构造直接抛 TypeError,使登录在发请求前就失败。
263
+ * 清洗后再 trim,结果为空返回 undefined(调用方据此跳过该头)。**永不抛异常**。
264
+ */
265
+ function headerSafeValue(value) {
266
+ if (typeof value !== 'string')
267
+ return undefined;
268
+ let out = '';
269
+ for (const char of value) {
270
+ const code = char.codePointAt(0) ?? 0;
271
+ if (code >= 0x20 && code <= 0x7e)
272
+ out += char;
273
+ }
274
+ const trimmed = out.trim();
275
+ return trimmed === '' ? undefined : trimmed;
276
+ }
277
+ /**
278
+ * deviceId 是网关侧落库与灰度分桶的键,必须整体是可见 ASCII(非空)才可信:
279
+ * 含非 ASCII / 控制字符 / 空白的 id 一律视为「设备未知」,宁可整条设备信息不发,
280
+ * 也不发送一个被清洗截断的假 id。**永不抛异常**。
281
+ */
282
+ function safeDeviceId(value) {
283
+ if (typeof value !== 'string')
284
+ return undefined;
285
+ const trimmed = value.trim();
286
+ return /^[\x21-\x7e]+$/.test(trimmed) ? trimmed : undefined;
287
+ }
288
+ /**
289
+ * 设备信息 → 请求头。四个字段一律先清洗:
290
+ * `deviceId` 不可信(见 {@link safeDeviceId})时一个头都不发;其余字段经
291
+ * {@link headerSafeValue} 清洗后为空则该头跳过、其余照发。
292
+ * 任何异常输入都不得让登录失败——本函数总是返回可安全交给 `Headers` 的对象。
293
+ */
294
+ export function deviceHeaders(device) {
295
+ const deviceId = device?.deviceId === undefined ? undefined : safeDeviceId(device.deviceId);
296
+ if (deviceId === undefined)
297
+ return {};
298
+ const headers = { 'X-HiWork-Device-Id': deviceId };
299
+ const deviceName = device?.deviceName === undefined ? undefined : headerSafeValue(device.deviceName);
300
+ if (deviceName !== undefined)
301
+ headers['X-HiWork-Device-Name'] = deviceName;
302
+ const platform = device?.platform === undefined ? undefined : headerSafeValue(device.platform);
303
+ if (platform !== undefined)
304
+ headers['X-HiWork-Platform'] = platform;
305
+ const appVersion = device?.appVersion === undefined ? undefined : headerSafeValue(device.appVersion);
306
+ if (appVersion !== undefined)
307
+ headers['X-HiWork-App-Version'] = appVersion;
308
+ return headers;
309
+ }
88
310
  /**
89
311
  * 从 Set-Cookie 头解析 session_id:遍历全部 Set-Cookie(网关可能先下发 CSRF 等其他 cookie);
90
312
  * 旧运行时回退 get('set-cookie') 时可能拿到逗号合并串,故分隔符同时接受 `,` 与 `;`。
@@ -100,3 +322,37 @@ export function extractSessionId(headers) {
100
322
  }
101
323
  return undefined;
102
324
  }
325
+ /** 读取 JSON 响应体:解析失败返回 undefined(不抛错、不记录原文,避免敏感字段进日志)。 */
326
+ async function readJson(res) {
327
+ try {
328
+ return await res.json();
329
+ }
330
+ catch {
331
+ return undefined;
332
+ }
333
+ }
334
+ /**
335
+ * 拼接 /api/me 查询串:值为 `undefined` 的键**不出现**在 URL 里(网关据此走缺省),
336
+ * 其余值一律 encodeURIComponent。全为缺省时返回空串(不带裸 `?`)。
337
+ */
338
+ function meQuery(params) {
339
+ const pairs = [];
340
+ for (const [key, value] of params) {
341
+ if (value === undefined)
342
+ continue;
343
+ pairs.push(`${key}=${encodeURIComponent(value)}`);
344
+ }
345
+ return pairs.length === 0 ? '' : `?${pairs.join('&')}`;
346
+ }
347
+ /** 解析 /api/me 响应体:非 JSON(或 JSON null)按网关异常处理,不回显响应片段。 */
348
+ async function parseMeJson(res) {
349
+ const data = await readJson(res);
350
+ if (data === undefined || data === null) {
351
+ throw new GatewayError('GATEWAY_UNREACHABLE', '网关返回的响应无法解析,请稍后重试', res.status);
352
+ }
353
+ return data;
354
+ }
355
+ /** 判定 400 响应体是否为 invalid_grant(只读 error 字段)。 */
356
+ function isInvalidGrant(payload) {
357
+ return typeof payload === 'object' && payload !== null && payload.error === 'invalid_grant';
358
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * 浏览器授权登录(`hiwork://` + PKCE)的宿主侧编排:发起与回调兑换。
3
+ *
4
+ * 接缝(与 pendingLogin / refreshRequest 同一机制,**不引入新的 RPC 通道**):
5
+ * - 客户端写 `browserLoginRequest: { at }` 请求发起浏览器登录;
6
+ * - 宿主发起后把授权 URL 回写为 `browserLogin: { authorizeUrl, startedAt }` 并清掉
7
+ * `browserLoginRequest`,由渲染端(client 半边)负责 `window.open(authorizeUrl)`;
8
+ * - 桌面端收到 `hiwork://auth/callback` 后经 IPC 调 `completeBrowserLogin({ code, state })`。
9
+ *
10
+ * 安全约束(见 spec §5.2):PKCE S256 是核心防线,state 严格相等才兑换,code 一次性,
11
+ * 任何日志都不得输出 code / code_verifier / code_challenge / session_id 明文。
12
+ */
13
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
14
+ import { hostname } from 'node:os';
15
+ import { buildAuthorizeURL } from "./ai-router-client.js";
16
+ // 供给尾部与端口契约在 provision-session.ts(该模块不反向 import 本文件,无循环依赖)。
17
+ import { provisionSession, toLoginError, } from "./provision-session.js";
18
+ import { SESSION_REF } from "./provision.js";
19
+ import { PKG_VERSION } from "./version.js";
20
+ /** 待完成授权请求的有效期:超过 10 分钟的回调一律拒绝(授权页/一次性 code 早已失效)。 */
21
+ export const BROWSER_LOGIN_TTL_MS = 10 * 60 * 1000;
22
+ /** 设备 id 在 credentials 里的兜底 ref(桌面端 Task C3 未推来时的本地持久化)。 */
23
+ export const DEVICE_ID_REF = 'hiwork_device_id';
24
+ /** 缺 baseURL 时写进 lastError 的稳定错误(发起与分发两侧共用同一文案)。 */
25
+ export const NO_BASE_URL_ERROR = {
26
+ code: 'GATEWAY_UNREACHABLE',
27
+ message: '网关地址未配置,无法发起浏览器登录',
28
+ };
29
+ /** 桌面端经 IPC 推来的设备信息(`hiwork:device-info`);进程内缓存,device_id 由桌面端拥有。 */
30
+ let pushedDeviceInfo;
31
+ /** 待完成的授权请求(模块级单例:同一时刻只允许一次浏览器登录)。 */
32
+ let pendingAuth;
33
+ /**
34
+ * 接收桌面端经 IPC 推来的设备信息(Task B3 的 `hiwork:device-info` 调用)。
35
+ * deviceId 为空/纯空白视为无效并忽略(保留既有缓存);deviceName / appVersion 可选。
36
+ * `appVersion` 是**桌面端版本**(网关把它记进会话并在授权页展示),缺失时才回退到插件版本。
37
+ */
38
+ export function setDeviceInfo(info) {
39
+ const deviceId = typeof info.deviceId === 'string' ? info.deviceId.trim() : '';
40
+ if (deviceId === '')
41
+ return;
42
+ const deviceName = typeof info.deviceName === 'string' ? info.deviceName.trim() : '';
43
+ const appVersion = typeof info.appVersion === 'string' ? info.appVersion.trim() : '';
44
+ pushedDeviceInfo = {
45
+ deviceId,
46
+ ...(deviceName === '' ? {} : { deviceName }),
47
+ ...(appVersion === '' ? {} : { appVersion }),
48
+ };
49
+ }
50
+ /** 清空模块级状态(IPC 设备缓存 + 待完成授权);供测试与插件卸载使用。 */
51
+ export function resetBrowserLoginState() {
52
+ pushedDeviceInfo = undefined;
53
+ pendingAuth = undefined;
54
+ }
55
+ /** 主机名(设备名兜底);取不到时返回空串,调用方按「未声明」处理。 */
56
+ function hostName() {
57
+ try {
58
+ return hostname().trim();
59
+ }
60
+ catch {
61
+ return '';
62
+ }
63
+ }
64
+ /**
65
+ * 解析本次登录要上报的设备身份:IPC 推来的值 → credentials 里的 `hiwork_device_id`
66
+ * → 都没有则生成 UUID 并持久化。**永远返回非空 deviceId**,调用方不必判空。
67
+ */
68
+ export async function resolveDeviceInfo(credentials) {
69
+ const platform = process.platform;
70
+ const appVersion = PKG_VERSION;
71
+ const pushed = pushedDeviceInfo;
72
+ if (pushed !== undefined) {
73
+ // 桌面端是 device_id 的唯一来源:把它的值同步进 credentials 兜底,
74
+ // 避免下次启动在 IPC 到达前用本地生成的旧 id 登录(导致同一机器两个 id)。
75
+ const stored = await credentials.resolve(DEVICE_ID_REF).catch(() => undefined);
76
+ if (stored?.value.trim() !== pushed.deviceId) {
77
+ await credentials.set(DEVICE_ID_REF, pushed.deviceId).catch(() => { });
78
+ }
79
+ return { deviceId: pushed.deviceId, ...(pushed.deviceName === undefined ? {} : { deviceName: pushed.deviceName }), platform, appVersion };
80
+ }
81
+ const stored = await credentials.resolve(DEVICE_ID_REF).catch(() => undefined);
82
+ const storedId = stored?.value.trim();
83
+ if (storedId !== undefined && storedId !== '') {
84
+ return { deviceId: storedId, deviceName: hostName(), platform, appVersion };
85
+ }
86
+ const generated = randomUUID();
87
+ // 持久化失败(凭据服务缺失)不阻塞登录:本次会话用生成的 id,下次重新生成。
88
+ await credentials.set(DEVICE_ID_REF, generated).catch(() => { });
89
+ return { deviceId: generated, deviceName: hostName(), platform, appVersion };
90
+ }
91
+ /** 32 字节随机 → base64url(PKCE code_verifier / state 的编码形式)。 */
92
+ function randomToken(bytes) {
93
+ return randomBytes(bytes).toString('base64url');
94
+ }
95
+ /**
96
+ * PKCE S256:`base64url(sha256(verifier))`(RFC 7636 §4.2)。导出以便单测用官方测试向量核对。
97
+ */
98
+ export function pkceChallenge(codeVerifier) {
99
+ return createHash('sha256').update(codeVerifier).digest('base64url');
100
+ }
101
+ /**
102
+ * 发起浏览器授权登录:生成 PKCE 与 state、暂存 pendingAuth、拼授权 URL 并回写命名空间。
103
+ * @returns 授权 URL 与 state;缺 baseURL 时写 lastError 并返回 undefined。
104
+ */
105
+ export async function startBrowserLogin(opts) {
106
+ const baseURL = opts.value.baseURL;
107
+ if (baseURL === undefined || baseURL === '') {
108
+ await opts.state.patch({
109
+ set: { lastError: NO_BASE_URL_ERROR },
110
+ unset: ['browserLoginRequest', 'browserLogin'],
111
+ }).catch(() => { });
112
+ return undefined;
113
+ }
114
+ const now = opts.now ?? Date.now;
115
+ const startedAt = now();
116
+ const codeVerifier = randomToken(32);
117
+ const state = randomToken(16);
118
+ const device = await resolveDeviceInfo(opts.credentials);
119
+ pendingAuth = { state, codeVerifier, deviceId: device.deviceId, startedAt };
120
+ const authorizeUrl = buildAuthorizeURL({
121
+ baseURL,
122
+ codeChallenge: pkceChallenge(codeVerifier),
123
+ state,
124
+ deviceId: device.deviceId,
125
+ deviceName: device.deviceName ?? '',
126
+ platform: device.platform ?? process.platform,
127
+ appVersion: device.appVersion ?? PKG_VERSION,
128
+ });
129
+ try {
130
+ await opts.state.patch({
131
+ set: { browserLogin: { authorizeUrl, startedAt: new Date(startedAt).toISOString() } },
132
+ unset: ['browserLoginRequest', 'lastError'],
133
+ });
134
+ }
135
+ catch (error) {
136
+ // 回写失败等同发起失败:清掉 pendingAuth,避免留下无人认领的授权请求。
137
+ pendingAuth = undefined;
138
+ throw error;
139
+ }
140
+ return { authorizeUrl, state };
141
+ }
142
+ /** 把失败写进状态并结束等待态(清 browserLogin 与请求字段)。 */
143
+ async function failBrowserLogin(state, loginError) {
144
+ await state.patch({
145
+ set: { lastError: loginError },
146
+ unset: ['browserLoginRequest', 'browserLogin'],
147
+ }).catch(() => { });
148
+ return loginError;
149
+ }
150
+ /**
151
+ * 宿主启动时对账持久化的等待态。
152
+ *
153
+ * PKCE 的 code_verifier 只活在内存的 `pendingAuth` 里,**不落任何持久化**;而等待态
154
+ * `browserLogin` 是持久化的。宿主一旦重启(应用重开、DSH 崩溃重启、开发期 HMR),
155
+ * 两者就永久错位:所有回调都被 `completeBrowserLogin` 的「没有待完成的授权请求」丢弃,
156
+ * 界面却仍按持久化的 `browserLogin` 停在「等待完成授权」,用户只能靠「取消」脱困。
157
+ *
158
+ * 因此启动即把这种**不可能完成**的等待态收尾成失败:写一条 lastError 并清掉等待字段,
159
+ * 让用户回到登录入口重新发起。没有等待态时不动任何状态。
160
+ */
161
+ export async function reconcileStaleBrowserLogin(state, value) {
162
+ if (value.browserLogin === undefined)
163
+ return;
164
+ await state.patch({
165
+ set: { lastError: { code: 'AUTH_FAILED', message: '上次浏览器授权未完成,请重新登录' } },
166
+ unset: ['browserLoginRequest', 'browserLogin'],
167
+ }).catch(() => { });
168
+ }
169
+ /**
170
+ * 处理 `hiwork://auth/callback`:校验 state → 兑换一次性 code → 写会话凭据 → 复用供给尾部。
171
+ *
172
+ * 一次性语义:state **已匹配**的路径(超时 / error / 缺 code / 兑换失败 / 成功)一律在
173
+ * finally 里清空 pendingAuth,重复回调只生效一次。
174
+ *
175
+ * 安全例外:state 不匹配的回调是**纯丢弃**——它可能来自攻击者伪造,因此不得写状态、不得
176
+ * 清 pendingAuth、不得触碰凭据(见下方早返回,该路径刻意绕过 finally)。否则一次伪造回调
177
+ * 就能让随后真实的回调被当成「无待完成授权」而忽略,等于无凭据即可打断用户登录。
178
+ */
179
+ export async function completeBrowserLogin(input, opts) {
180
+ const now = opts.now ?? Date.now;
181
+ const log = opts.log ?? (() => { });
182
+ const pending = pendingAuth;
183
+ if (pending === undefined) {
184
+ // 没有待完成的授权:重复回调 / 重启后的旧回调。丢弃,绝不触碰已有会话。
185
+ log('hiwork-core: 收到浏览器授权回调但没有待完成的授权请求,已忽略');
186
+ return null;
187
+ }
188
+ if (typeof input.state !== 'string' || input.state !== pending.state) {
189
+ // 疑似劫持:纯丢弃 + 告警。不调用 exchangeCode、不写 lastError、不改命名空间,
190
+ // 也**不清 pendingAuth**——伪造回调不得影响进行中的授权,真实回调随后仍能完成登录。
191
+ log('hiwork-core: 浏览器授权回调 state 校验失败(疑似劫持),已丢弃本次回调');
192
+ return null;
193
+ }
194
+ try {
195
+ if (now() - pending.startedAt > BROWSER_LOGIN_TTL_MS) {
196
+ log('hiwork-core: 浏览器授权回调已超时(超过 10 分钟),已拒绝');
197
+ return await failBrowserLogin(opts.state, { code: 'AUTH_FAILED', message: '授权已超时,请重新登录' });
198
+ }
199
+ if (typeof input.error === 'string' && input.error !== '') {
200
+ // 授权页拒绝 / 网关下发 error:不兑换 code。error 截断后入日志,防日志注入。
201
+ log(`hiwork-core: 浏览器授权被拒绝(${input.error.slice(0, 64)})`);
202
+ return await failBrowserLogin(opts.state, { code: 'AUTH_FAILED', message: '授权被取消或失败,请重新登录' });
203
+ }
204
+ if (typeof input.code !== 'string' || input.code === '') {
205
+ log('hiwork-core: 浏览器授权回调缺少 code,已丢弃');
206
+ return await failBrowserLogin(opts.state, { code: 'AUTH_FAILED', message: '授权回调不完整,请重新登录' });
207
+ }
208
+ const baseURL = opts.value.baseURL;
209
+ if (baseURL === undefined || baseURL === '')
210
+ return await failBrowserLogin(opts.state, NO_BASE_URL_ERROR);
211
+ const { sessionCookie, user } = await opts.gateway.exchangeCode({
212
+ code: input.code,
213
+ codeVerifier: pending.codeVerifier,
214
+ deviceId: pending.deviceId,
215
+ });
216
+ await opts.credentials.set(SESSION_REF, sessionCookie);
217
+ const username = typeof user.username === 'string' && user.username.trim() !== '' ? user.username.trim() : undefined;
218
+ await provisionSession({
219
+ gateway: opts.gateway,
220
+ credentials: opts.credentials,
221
+ piAi: opts.piAi,
222
+ state: opts.state,
223
+ baseURL,
224
+ value: opts.value,
225
+ sessionCookie,
226
+ user,
227
+ username,
228
+ requestUnset: ['browserLoginRequest', 'browserLogin'],
229
+ ...(opts.defaultModel === undefined ? {} : { defaultModel: opts.defaultModel }),
230
+ ...(opts.ensureWorkspace === undefined ? {} : { ensureWorkspace: opts.ensureWorkspace }),
231
+ });
232
+ return null;
233
+ }
234
+ catch (error) {
235
+ const loginError = toLoginError(error, '无法连接网关,请检查 baseURL 或网络');
236
+ return await failBrowserLogin(opts.state, loginError);
237
+ }
238
+ finally {
239
+ pendingAuth = undefined;
240
+ }
241
+ }