hiwork-core 0.0.38 → 0.0.39

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.
@@ -1,4 +1,19 @@
1
1
  import { gatewayUserAgent } from "./user-agent.js";
2
+ /** 模块级活动 sink:由宿主(index.ts)在 apply 时挂、在 effect 清理时摘。 */
3
+ let sessionActivitySink;
4
+ /** 挂载活动 sink(传 undefined 等价于清除,便于测试与宿主无处可摘时兜底)。 */
5
+ export function setSessionActivitySink(sink) {
6
+ sessionActivitySink = sink;
7
+ }
8
+ /**
9
+ * 清除活动 sink,**仅当当前 sink 就是入参那一个**。
10
+ * HMR 会重挂同一个模块:旧 fiber 的清理回调可能在新的 sink 挂上之后才跑,
11
+ * 无条件清空会把新挂载的观察者一起摘掉(心跳与 401 汇聚会静默失效)。
12
+ */
13
+ export function clearSessionActivitySink(sink) {
14
+ if (sessionActivitySink === sink)
15
+ sessionActivitySink = undefined;
16
+ }
2
17
  /**
3
18
  * 读错误响应体里的结构化 `code`(读不到 → 空串)。
4
19
  *
@@ -18,6 +33,8 @@ async function knowledgeErrorCodeFromBody(res) {
18
33
  const AUTH_AUTHORIZE_PATH = '/api/auth/authorize';
19
34
  const AUTH_TOKEN_PATH = '/api/auth/token';
20
35
  const AUTH_SESSIONS_PATH = '/api/auth/sessions';
36
+ /** refresh grant 静默重认证端点(M2 网关起;老网关无此路由回 404)。 */
37
+ const AUTH_REFRESH_PATH = '/api/auth/refresh';
21
38
  /** 自助用量与成本端点(均挂在网关 /api/me 会话组下,鉴权由 SessionAuth 负责)。 */
22
39
  const ME_ANALYTICS_SUMMARY_PATH = '/api/me/analytics/summary';
23
40
  const ME_ANALYTICS_TRENDS_PATH = '/api/me/analytics/trends';
@@ -254,8 +271,22 @@ export class AiRouterClient {
254
271
  throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
255
272
  if (sid === undefined)
256
273
  throw new GatewayError('GATEWAY_UNREACHABLE', '网关登录成功但未返回会话 Cookie', res.status);
257
- const user = await res.json();
258
- return { sessionCookie: `session_id=${sid}`, user };
274
+ // 表单登录响应体带 expires_at(网关 C5 起)与 refresh_token(M2 起):前者优先于 Set-Cookie
275
+ // 的过期属性,两者都没有(旧网关)时留空,由调用方的「按假定寿命估算」路径接手。
276
+ // 两个字段都从 user 上摘掉(refresh_token 是密钥,绝不能混进 GatewayUser 被当作展示字段)。
277
+ const { expires_at: rawExpiresAt, refresh_token: rawRefreshToken, ...user } = await res.json();
278
+ const bodyExpiresAt = typeof rawExpiresAt === 'string' && !Number.isNaN(Date.parse(rawExpiresAt))
279
+ ? rawExpiresAt
280
+ : undefined;
281
+ const sessionExpiresAt = bodyExpiresAt ?? parseSessionCookieExpiry(res.headers);
282
+ // grant 只收「字符串且非空」:空串/非字符串一律当老网关没给,调用方据此清陈旧 grant。
283
+ const refreshToken = typeof rawRefreshToken === 'string' && rawRefreshToken !== '' ? rawRefreshToken : undefined;
284
+ return {
285
+ sessionCookie: `session_id=${sid}`,
286
+ user,
287
+ ...(sessionExpiresAt === undefined ? {} : { sessionExpiresAt }),
288
+ ...(refreshToken === undefined ? {} : { refreshToken }),
289
+ };
259
290
  }
260
291
  /**
261
292
  * POST /api/auth/token:用一次性 code + PKCE verifier 换取会话。
@@ -282,7 +313,62 @@ export class AiRouterClient {
282
313
  if (data === undefined || data.session_id === undefined || data.session_id === '') {
283
314
  throw new GatewayError('GATEWAY_UNREACHABLE', '网关兑换成功但未返回会话标识', res.status);
284
315
  }
285
- return { sessionCookie: `session_id=${data.session_id}`, user: data.user };
316
+ // 服务端已回 expires_at(spec §4.1a);只收「字符串且可解析」的值,原样保留服务端给的
317
+ // 时刻串,坏值当未知交给估算路径。
318
+ const rawExpiresAt = typeof data.expires_at === 'string' ? data.expires_at : undefined;
319
+ const sessionExpiresAt = rawExpiresAt !== undefined && !Number.isNaN(Date.parse(rawExpiresAt))
320
+ ? rawExpiresAt
321
+ : undefined;
322
+ // grant 同样「字符串非空才收」(M2 起下发;老网关缺失即 undefined)。
323
+ const refreshToken = typeof data.refresh_token === 'string' && data.refresh_token !== '' ? data.refresh_token : undefined;
324
+ return {
325
+ sessionCookie: `session_id=${data.session_id}`,
326
+ user: data.user,
327
+ ...(sessionExpiresAt === undefined ? {} : { sessionExpiresAt }),
328
+ ...(refreshToken === undefined ? {} : { refreshToken }),
329
+ };
330
+ }
331
+ /**
332
+ * POST /api/auth/refresh:用 refresh grant 换一份新会话(M2 网关的静默重认证)。
333
+ *
334
+ * 拒绝语义必须分开,调用方据此决定「弃用 grant」还是「保留待重试」:
335
+ * - 400 = grant 无效(过期 / 已被轮换 / 复用检测拒绝)→ `REFRESH_GRANT_INVALID`,本地作废;
336
+ * - 404 = 老网关根本没有这条路由 → `REFRESH_GRANT_ABSENT`,本地作废且**静默降级**;
337
+ * - 其余非 2xx(5xx / 反代错误页)与网络层异常 → `GATEWAY_UNREACHABLE`,grant 保留,
338
+ * 下次 SESSION_EXPIRED 再试。
339
+ *
340
+ * 任何日志/异常信息都不得包含 refresh_token / session_id 明文。
341
+ */
342
+ async refreshSession(refreshToken) {
343
+ const res = await this.requestOrUnreachable(AUTH_REFRESH_PATH, {
344
+ method: 'POST',
345
+ body: { refresh_token: refreshToken },
346
+ });
347
+ if (res.status === 400)
348
+ throw new GatewayError('REFRESH_GRANT_INVALID', '登录已过期,请重新登录', 400);
349
+ if (res.status === 404)
350
+ throw new GatewayError('REFRESH_GRANT_ABSENT', '网关不支持静默续期', 404);
351
+ if (!res.ok)
352
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
353
+ // 与 exchangeCode 同理用 readJson(不把响应片段带进异常信息)。
354
+ const data = await readJson(res);
355
+ // 响应体优先取 session_id(契约字段);网关只下发 Set-Cookie 时回落解析 Cookie,
356
+ // 两处都没有才算契约违约(与 login 的判据一致)。
357
+ const sid = typeof data?.session_id === 'string' && data.session_id !== '' ? data.session_id : extractSessionId(res.headers);
358
+ if (data === undefined || data.user === undefined || sid === undefined) {
359
+ throw new GatewayError('GATEWAY_UNREACHABLE', '网关续期成功但未返回会话标识', res.status);
360
+ }
361
+ const rawExpiresAt = typeof data.expires_at === 'string' ? data.expires_at : undefined;
362
+ const sessionExpiresAt = rawExpiresAt !== undefined && !Number.isNaN(Date.parse(rawExpiresAt))
363
+ ? rawExpiresAt
364
+ : undefined;
365
+ const rotated = typeof data.refresh_token === 'string' && data.refresh_token !== '' ? data.refresh_token : undefined;
366
+ return {
367
+ sessionCookie: `session_id=${sid}`,
368
+ user: data.user,
369
+ ...(sessionExpiresAt === undefined ? {} : { sessionExpiresAt }),
370
+ ...(rotated === undefined ? {} : { refreshToken: rotated }),
371
+ };
286
372
  }
287
373
  /** GET /api/auth/sessions:当前账号的登录设备列表。 */
288
374
  async listSessions(sessionCookie) {
@@ -551,6 +637,11 @@ export class AiRouterClient {
551
637
  if (res.status === 503) {
552
638
  throw new GatewayError('MCP_ASSERTION_DISABLED', '本部署未启用 MCP 断言签发,请联系管理员', 503);
553
639
  }
640
+ // 429:服务端限流(每用户 60 次/分)。与其余拒绝不同,这不是终局——调用方(MCP 同步)
641
+ // 吞掉单条失败等下一轮即可,故给独立 code 让消费方能区分「等一会」与「找管理员」。
642
+ if (res.status === 429) {
643
+ throw new GatewayError('MCP_ASSERTION_RATE_LIMITED', '断言签发过于频繁,稍后自动重试', 429);
644
+ }
554
645
  if (!res.ok)
555
646
  throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
556
647
  return await parseMeJson(res);
@@ -969,7 +1060,26 @@ export class AiRouterClient {
969
1060
  }
970
1061
  else if (opts.body !== undefined)
971
1062
  init.body = JSON.stringify(opts.body);
972
- return fetch(`${this.baseURL}${path}`, init);
1063
+ const res = await fetch(`${this.baseURL}${path}`, init);
1064
+ // 只有「带会话的请求」才产生活动信号:登录/兑换等尚无会话的请求不参与心跳与恢复。
1065
+ if (opts.sessionCookie !== undefined) {
1066
+ try {
1067
+ if (res.status === 401)
1068
+ sessionActivitySink?.({ kind: 'unauthorized' });
1069
+ else if (res.ok) {
1070
+ // 响应头带会话绝对寿命(网关 C5 起):透传给宿主更新内存估计;不可解析当缺失。
1071
+ const headerExpiresAt = res.headers.get('x-session-expires-at');
1072
+ const expiresAt = headerExpiresAt !== null && !Number.isNaN(Date.parse(headerExpiresAt))
1073
+ ? headerExpiresAt
1074
+ : undefined;
1075
+ sessionActivitySink?.(expiresAt === undefined ? { kind: 'success' } : { kind: 'success', expiresAt });
1076
+ }
1077
+ }
1078
+ catch {
1079
+ // sink 是外部注入的观察者:它的异常绝不能影响请求结果本身。
1080
+ }
1081
+ }
1082
+ return res;
973
1083
  }
974
1084
  }
975
1085
  /**
@@ -1038,6 +1148,44 @@ export function extractSessionId(headers) {
1038
1148
  }
1039
1149
  return undefined;
1040
1150
  }
1151
+ /**
1152
+ * 从 Set-Cookie 解析会话过期时间(ISO 串;解析不出返回 undefined)。
1153
+ * 与 {@link extractSessionId} 同源遍历全部 Set-Cookie,但只认 `session_id` 那一节里的
1154
+ * `Expires=`(绝对时刻,`Date.parse`,NaN 弃用)或 `Max-Age=`(相对秒数,now + 秒);
1155
+ * 两者都有优先 `Expires`——服务端给的是权威时刻,Max-Age 只是保守回退。
1156
+ * 旧网关可能两个属性都不带(网关契约尚未下发过期信息),此时返回 undefined,
1157
+ * 由调用方的「按假定寿命估算」路径接手,绝不在这里抛错或猜一个假值。
1158
+ *
1159
+ * 旧运行时回退 `get('set-cookie')` 会拿到逗号合并串,故先按「逗号 + cookie 名=」切节:
1160
+ * `Expires` 的日期串里也有逗号,但逗号后面不是 `name=`,不会被误切。
1161
+ * `now` 可注入以便测试(默认 `Date.now()`)。
1162
+ */
1163
+ export function parseSessionCookieExpiry(headers, now = Date.now()) {
1164
+ const raws = typeof headers.getSetCookie === 'function'
1165
+ ? headers.getSetCookie()
1166
+ : [headers.get('set-cookie')].filter((raw) => raw !== null);
1167
+ for (const raw of raws) {
1168
+ for (const section of raw.split(/,\s*(?=[!#$%&'*+\-.^_`|~0-9A-Za-z]+=)/)) {
1169
+ if (!/(?:^|;\s*)session_id=/.test(section))
1170
+ continue;
1171
+ const expires = /;\s*Expires=([^;]+)/i.exec(section);
1172
+ const expiresRaw = expires?.[1];
1173
+ if (expiresRaw !== undefined) {
1174
+ const ms = Date.parse(expiresRaw);
1175
+ if (!Number.isNaN(ms))
1176
+ return new Date(ms).toISOString();
1177
+ }
1178
+ const maxAge = /;\s*Max-Age=(-?\d+)/i.exec(section);
1179
+ if (maxAge !== null) {
1180
+ const seconds = Number(maxAge[1]);
1181
+ if (Number.isFinite(seconds))
1182
+ return new Date(now + seconds * 1000).toISOString();
1183
+ }
1184
+ return undefined;
1185
+ }
1186
+ }
1187
+ return undefined;
1188
+ }
1041
1189
  /** 读取 JSON 响应体:解析失败返回 undefined(不抛错、不记录原文,避免敏感字段进日志)。 */
1042
1190
  async function readJson(res) {
1043
1191
  try {
@@ -15,7 +15,7 @@ import { hostname } from 'node:os';
15
15
  import { buildAuthorizeURL } from "./ai-router-client.js";
16
16
  // 供给尾部与端口契约在 provision-session.ts(该模块不反向 import 本文件,无循环依赖)。
17
17
  import { provisionSession, toLoginError, } from "./provision-session.js";
18
- import { SESSION_REF } from "./provision.js";
18
+ import { REFRESH_TOKEN_REF, SESSION_REF } from "./provision.js";
19
19
  import { PKG_VERSION } from "./version.js";
20
20
  /** 待完成授权请求的有效期:超过 10 分钟的回调一律拒绝(授权页/一次性 code 早已失效)。 */
21
21
  export const BROWSER_LOGIN_TTL_MS = 10 * 60 * 1000;
@@ -241,12 +241,18 @@ export async function completeBrowserLogin(input, opts) {
241
241
  const baseURL = opts.value.baseURL;
242
242
  if (baseURL === undefined || baseURL === '')
243
243
  return await failBrowserLogin(opts.state, NO_BASE_URL_ERROR, log);
244
- const { sessionCookie, user } = await opts.gateway.exchangeCode({
244
+ const { sessionCookie, user, sessionExpiresAt, refreshToken } = await opts.gateway.exchangeCode({
245
245
  code: input.code,
246
246
  codeVerifier: pending.codeVerifier,
247
247
  deviceId: pending.deviceId,
248
248
  });
249
249
  await opts.credentials.set(SESSION_REF, sessionCookie);
250
+ // 与表单登录同批落盘(浏览器授权是主登录面,缺了这一步静默续期对它永远无效):
251
+ // 有 grant 即写,老网关没给则清掉残留旧值(防上一个账号的 grant 与新会话并存)。
252
+ if (refreshToken === undefined)
253
+ await opts.credentials.unset(REFRESH_TOKEN_REF).catch(() => { });
254
+ else
255
+ await opts.credentials.set(REFRESH_TOKEN_REF, refreshToken);
250
256
  const username = typeof user.username === 'string' && user.username.trim() !== '' ? user.username.trim() : undefined;
251
257
  await provisionSession({
252
258
  gateway: opts.gateway,
@@ -258,6 +264,7 @@ export async function completeBrowserLogin(input, opts) {
258
264
  sessionCookie,
259
265
  user,
260
266
  username,
267
+ sessionExpiresAt,
261
268
  requestUnset: ['browserLoginRequest', 'browserLogin'],
262
269
  ...(opts.defaultModel === undefined ? {} : { defaultModel: opts.defaultModel }),
263
270
  ...(opts.ensureWorkspace === undefined ? {} : { ensureWorkspace: opts.ensureWorkspace }),
@@ -2,9 +2,11 @@
2
2
  * 客户端版本策略(spec §6):`GET /api/client/policy` 的免登录只读拉取。
3
3
  *
4
4
  * 网关返回的是**最终判定**(`loginMode` 等),插件只做投影不做策略逻辑,因此管理端
5
- * 调整 `client.login_mode` 后重启插件即生效,无需发版(spec §6.1)。
5
+ * 调整 `client.login_mode` 后无需重启插件、无需发版:由 {@link createClientPolicyPoller}
6
+ * 每 6 小时重拉一次(对齐服务端 `Cache-Control: max-age=300` 的注释预期),失败按
7
+ * 1min → 10min → 1h 阶梯退避重试,紧急止血策略(如 `login_mode=form`)由此能到达在线客户端。
6
8
  *
7
- * 本模块只负责「拉 + 严格解析」,投影与 fail-open 缺省由 `src/index.ts` 的
9
+ * 本模块只负责「拉 + 严格解析 + 轮询节律」,投影与 fail-open 缺省由 `src/index.ts` 的
8
10
  * `projectLoginMode` 完成:**非 200 / 非 JSON / 枚举非法 / 超时 / 网络异常一律返回
9
11
  * undefined**,调用方据此回退 `DEFAULT_LOGIN_MODE`,绝不把用户锁在门外(spec §5.4 / §6.4)。
10
12
  */
@@ -92,3 +94,76 @@ export async function fetchClientPolicy(opts) {
92
94
  return undefined;
93
95
  }
94
96
  }
97
+ /** 策略轮询间隔(6h,对齐服务端 `Cache-Control: max-age=300` 的注释预期)。 */
98
+ export const CLIENT_POLICY_POLL_INTERVAL_MS = 6 * 60 * 60_000;
99
+ /**
100
+ * 失败退避阶梯(1min → 10min → 1h),末档封顶循环。
101
+ * 退避只影响「多久后重试」,不影响已经投影好的策略值——失败时保留上一轮结果。
102
+ */
103
+ export const CLIENT_POLICY_RETRY_DELAYS_MS = [60_000, 10 * 60_000, 60 * 60_000];
104
+ /**
105
+ * 自排程的策略轮询器(不用 setInterval:每轮的间隔由上一轮结果决定)。
106
+ * - `attempt` 返回 true(成功拉到并投影)→ 6h 后再来;
107
+ * - 返回 false 或抛异常 → 按 {@link CLIENT_POLICY_RETRY_DELAYS_MS} 退避,末档封顶循环;
108
+ * - 任意一次成功把退避档归零(下一次失败重新从 1min 起步);
109
+ * - 定时器 `unref`,不拖住进程退出;`trigger()` 幂等(已排程/在飞时是 no-op),
110
+ * 让宿主 200ms 消费循环可以无脑反复调用。
111
+ */
112
+ export function createClientPolicyPoller(deps) {
113
+ let timer;
114
+ let disposed = false;
115
+ let running = false;
116
+ let retryIndex = 0;
117
+ const schedule = (delayMs) => {
118
+ if (disposed)
119
+ return;
120
+ if (timer !== undefined)
121
+ clearTimeout(timer);
122
+ timer = setTimeout(() => {
123
+ timer = undefined;
124
+ void run();
125
+ }, delayMs);
126
+ timer.unref?.();
127
+ };
128
+ const run = async () => {
129
+ if (disposed || running)
130
+ return;
131
+ running = true;
132
+ let ok = false;
133
+ try {
134
+ ok = await deps.attempt();
135
+ }
136
+ catch {
137
+ // attempt 的实现方约定「不抛」,但轮询器不能因此死掉:异常一律视同失败。
138
+ ok = false;
139
+ }
140
+ finally {
141
+ running = false;
142
+ }
143
+ if (disposed)
144
+ return;
145
+ if (ok) {
146
+ retryIndex = 0;
147
+ schedule(CLIENT_POLICY_POLL_INTERVAL_MS);
148
+ return;
149
+ }
150
+ const delay = CLIENT_POLICY_RETRY_DELAYS_MS[Math.min(retryIndex, CLIENT_POLICY_RETRY_DELAYS_MS.length - 1)];
151
+ retryIndex += 1;
152
+ deps.log?.(`hiwork-core: 客户端策略拉取失败,${Math.round(delay / 1000)}s 后重试`);
153
+ schedule(delay);
154
+ };
155
+ return {
156
+ trigger: () => {
157
+ if (disposed || running || timer !== undefined)
158
+ return;
159
+ void run();
160
+ },
161
+ dispose: () => {
162
+ disposed = true;
163
+ if (timer !== undefined) {
164
+ clearTimeout(timer);
165
+ timer = undefined;
166
+ }
167
+ },
168
+ };
169
+ }