hiwork-core 0.0.10 → 0.0.16
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 +6 -0
- package/lib/ai-router-client.js +310 -1
- package/lib/client.js +698 -5
- package/lib/index.js +1292 -2
- package/lib/managed-mcp.js +334 -0
- package/lib/managed-skills.js +446 -0
- package/lib/office-credentials.js +113 -0
- package/lib/office-relay.js +113 -0
- package/lib/types/ai-router-client.d.ts +314 -0
- package/lib/types/client/contracts.d.ts +63 -0
- package/lib/types/client/managed-mcp.d.ts +133 -0
- package/lib/types/client/managed-skills.d.ts +218 -0
- package/lib/types/client/managed-workbench.d.ts +212 -0
- package/lib/types/client/styles.d.ts +27 -0
- package/lib/types/client/types.d.ts +33 -1
- package/lib/types/managed-mcp.d.ts +177 -0
- package/lib/types/managed-skills.d.ts +261 -0
- package/lib/types/office-credentials.d.ts +99 -0
- package/lib/types/office-relay.d.ts +105 -0
- package/lib/types/workbench-bridge.d.ts +259 -0
- package/lib/workbench-bridge.js +447 -0
- package/package.json +1 -1
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/hiwork-managed-skills` loopback RPC 的 Host 实现:管理员下发的技能清单 + 按需取回的包体字节。
|
|
3
|
+
*
|
|
4
|
+
* 契约见 ai-hub 的 spec `docs/superpowers/specs/2026-09-17-skill-distribution-design.md` §4.5
|
|
5
|
+
* (Host 半边),清单字段见 §4.1、包体端点见 §4.2。本文件是 `src/managed-mcp.ts` 同一副骨架的
|
|
6
|
+
* **第二条链**——技能比 MCP 短一整段(没有服务端调用 ⇒ 没有凭证、没有断言、没有 mint/revoke),
|
|
7
|
+
* 所以刻意**不把 MCP 独有的东西搬过来**:端点少一个(`assertion`),也没有任何票据。
|
|
8
|
+
*
|
|
9
|
+
* 与 MCP 的实质差异有两处(第二处见 {@link ManagedSkillsStore.bindSession},那里写了为什么**不许**
|
|
10
|
+
* 为了「两半对齐」改回去):
|
|
11
|
+
* - `package` 会把**大字节**交给客户端(ZIP 的 base64),因此它是全频道唯一的带大小闸的端点
|
|
12
|
+
* ——spec §8 R8 说得很清楚,这是唯一一条把大字节经 Host 搬进客户端的路径;
|
|
13
|
+
* - 内存清单**按会话绑定**:缓存只属于产生它的那个会话,登出 / 换账号后绝不再吐出去。
|
|
14
|
+
*
|
|
15
|
+
* 与 MCP 逐字同款的两条铁律:
|
|
16
|
+
* - **网关会话 cookie 绝不离开 Host**:cookie 只在 `resolveSession()` 内部出现,客户端拿到的
|
|
17
|
+
* 是清单与包体字节(经 loopback RPC 以 base64 过去),浏览器侧永远拿不到包体,也零 fetch;
|
|
18
|
+
* - **拉清单失败绝不清空上一次成功的清单**:客户端只有在拿到一份「成功拉到的清单」时才允许
|
|
19
|
+
* 删除本地受管技能(spec §6 的唯一安全不变量),所以失败必须返回「保留旧值 + 标注失败原因」,
|
|
20
|
+
* 否则一次网络抖动就会让员工端把自己装好的技能删干净。
|
|
21
|
+
*
|
|
22
|
+
* 本模块不 import cordis 运行时,只依赖结构化的 {@link ManagedSkillsRpcContext} 端口,便于单测直接伪造。
|
|
23
|
+
*/
|
|
24
|
+
import { GatewayError, } from "./ai-router-client.js";
|
|
25
|
+
/** RPC 频道;必须与 client 半边 `src/client/managed-skills.ts` 的 `MANAGED_SKILLS_CHANNEL` 一致(由测试锁定)。 */
|
|
26
|
+
export const MANAGED_SKILLS_RPC_CHANNEL = '/hiwork-managed-skills';
|
|
27
|
+
/**
|
|
28
|
+
* 端点白名单。
|
|
29
|
+
*
|
|
30
|
+
* `snapshot` 是「你现在有什么」(有缓存就直接给,冷启动才拉一次),`refresh` 是「现在去拉一次」,
|
|
31
|
+
* `package` 是「把某一条的包体给我」。分三个端点的理由与 MCP 同款:客户端的同步策略(启动 /
|
|
32
|
+
* 30 分钟 / 打开能力中心且 >5min stale / 手动「立即同步」)由它自己定,Host 不该替它决定
|
|
33
|
+
* 什么时候必须联网;而 `package` 必须**按需单独取**——同步一轮只搬「真要装/更新」的那几条,
|
|
34
|
+
* 一轮带全等于每次同步都把所有技能包拉一遍(spec §4.6 的 `needsPackage` 就是这个形状)。
|
|
35
|
+
*/
|
|
36
|
+
export const MANAGED_SKILLS_RPC_ENDPOINTS = ['snapshot', 'refresh', 'package'];
|
|
37
|
+
/**
|
|
38
|
+
* Host 侧后台预热间隔:30 分钟(与客户端同步节奏同量级)。
|
|
39
|
+
* 它**不是**权威节奏——只为让客户端首次 `snapshot` 不必等一次网络往返。
|
|
40
|
+
*/
|
|
41
|
+
export const MANAGED_SKILLS_REFRESH_INTERVAL_MS = 30 * 60_000;
|
|
42
|
+
/**
|
|
43
|
+
* 包体上限:4 MiB(spec §2 的冻结决策)。ai-hub 侧的常量叫 `MAX_MANAGED_ARCHIVE_BYTES`,
|
|
44
|
+
* 两仓各自定格同一个数值、不是同一份代码;这里**不是**「与客户端同值」的那类约定
|
|
45
|
+
* (spec §5.4 第 5 步特地警告过:别把客户端 32 MiB 的浏览器上传门槛误当成本条)。
|
|
46
|
+
*
|
|
47
|
+
* 它拦的不是「服务端超发」(服务端入库与服务时各拦一次),而是**不让超大字节经 loopback RPC
|
|
48
|
+
* 搬进客户端**:base64 之后约 5.3 MiB 的字符串,是这条通道上唯一一次大对象传输(spec §8 R8)。
|
|
49
|
+
*/
|
|
50
|
+
export const MAX_MANAGED_SKILL_ARCHIVE_BYTES = 4 * 1024 * 1024;
|
|
51
|
+
/** 请求侧错误(只有 `bad-request`:未知端点 / 非法 payload)。 */
|
|
52
|
+
export class ManagedSkillsRequestError extends Error {
|
|
53
|
+
code;
|
|
54
|
+
constructor(code, message) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.code = code;
|
|
57
|
+
this.name = 'ManagedSkillsRequestError';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 各稳定错误码的用户可见文案(网关 message 含实现细节时用它兜底)。
|
|
62
|
+
* 只列本频道**真会收到**的 code:技能链路没有 `NO_KEY_ASSIGNED` 那一档
|
|
63
|
+
* (分技能与分 Key 是两件事,见 `getMySkillAssignments` 的注释)。
|
|
64
|
+
*/
|
|
65
|
+
const MANAGED_SKILLS_MESSAGE_FALLBACK = {
|
|
66
|
+
SESSION_EXPIRED: '登录状态已过期,请重新登录',
|
|
67
|
+
GATEWAY_UNREACHABLE: '网关暂时不可用,请稍后重试',
|
|
68
|
+
SKILL_ASSIGNMENT_GONE: '这条分配已失效,稍后同步会重新核对',
|
|
69
|
+
SKILL_DISTRIBUTION_DISABLED: '本部署未启用技能下发,请联系管理员',
|
|
70
|
+
};
|
|
71
|
+
/** 通用失败文案:不暴露任何网关细节。 */
|
|
72
|
+
export const MANAGED_SKILLS_GENERIC_FAILURE_MESSAGE = '技能清单暂时无法获取,请稍后重试';
|
|
73
|
+
/** 未登录 / 会话失效的统一文案(与用量、每日一句、MCP 清单几个频道同口径)。 */
|
|
74
|
+
export const MANAGED_SKILLS_SESSION_EXPIRED_MESSAGE = '登录状态已过期,请重新登录';
|
|
75
|
+
/** 收到超上限包体时的失败 code(服务端超发 / 契约被改大时才会走到)。 */
|
|
76
|
+
export const MANAGED_SKILLS_PACKAGE_TOO_LARGE_CODE = 'SKILL_PACKAGE_TOO_LARGE';
|
|
77
|
+
/** 超上限包体的文案:给员工一句可行动的话(找管理员),而不是一个数字。 */
|
|
78
|
+
export const MANAGED_SKILLS_PACKAGE_TOO_LARGE_MESSAGE = '这个技能包超过了大小上限,暂时装不上,请联系管理员';
|
|
79
|
+
/**
|
|
80
|
+
* 网关 message 消毒(与 `usage-rpc.ts` 的 `displayGatewayMessage` 同实现,MCP 那半逐字同款):
|
|
81
|
+
* 含 baseURL / HTTP 状态码 / URL 的原始消息一律换成稳定文案,其余(网关自己写好的中文业务文案)
|
|
82
|
+
* 原样保留。code 不受影响,始终原样透传。
|
|
83
|
+
*/
|
|
84
|
+
export function displayManagedSkillsMessage(code, message) {
|
|
85
|
+
const text = typeof message === 'string' ? message.trim() : '';
|
|
86
|
+
if (text === '')
|
|
87
|
+
return MANAGED_SKILLS_MESSAGE_FALLBACK[code] ?? MANAGED_SKILLS_GENERIC_FAILURE_MESSAGE;
|
|
88
|
+
const leaksImplementationDetail = /https?:\/\//i.test(text)
|
|
89
|
+
|| /\bHTTP\s*\d{3}\b/i.test(text)
|
|
90
|
+
|| /\bbaseURL\b/i.test(text);
|
|
91
|
+
if (leaksImplementationDetail)
|
|
92
|
+
return MANAGED_SKILLS_MESSAGE_FALLBACK[code] ?? MANAGED_SKILLS_GENERIC_FAILURE_MESSAGE;
|
|
93
|
+
return text;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* `package` 的 assignmentId 形状:只放行 uuid 用的字符集与长度(1–64)。
|
|
97
|
+
*
|
|
98
|
+
* 与 `managed-mcp.ts` 的同名常量**逐字相同但不是同一个**(那个没导出,也不该为了共用而改样板文件):
|
|
99
|
+
* 这是全频道唯一一处客户端可控输入,卡在 Host 比卡在网关早——一个带空格 / 换行 / 百分号的串
|
|
100
|
+
* 没有任何合法来源(服务端的 assignment id 是 uuid),直接 `bad-request`。
|
|
101
|
+
*/
|
|
102
|
+
const ASSIGNMENT_ID_PATTERN = /^[0-9a-fA-F-]{1,64}$/;
|
|
103
|
+
/**
|
|
104
|
+
* payload 校验:`snapshot` / `refresh` 只放行空对象,`package` 只放行 `{ assignmentId }`;
|
|
105
|
+
* 其余字段 / 类型不符一律 `bad-request`。
|
|
106
|
+
*/
|
|
107
|
+
export function parseManagedSkillsPayload(endpoint, raw) {
|
|
108
|
+
if (raw === undefined || raw === null) {
|
|
109
|
+
if (endpoint === 'package')
|
|
110
|
+
throw new ManagedSkillsRequestError('bad-request', '取技能包请求缺少 assignmentId。');
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
114
|
+
throw new ManagedSkillsRequestError('bad-request', '技能清单请求参数不合法。');
|
|
115
|
+
}
|
|
116
|
+
const record = raw;
|
|
117
|
+
const keys = Object.keys(record);
|
|
118
|
+
if (endpoint !== 'package') {
|
|
119
|
+
// snapshot / refresh 无任何入参:出现任何字段都是客户端实现缺陷。
|
|
120
|
+
if (keys.length > 0)
|
|
121
|
+
throw new ManagedSkillsRequestError('bad-request', `技能清单请求包含未知字段 '${keys[0]}'。`);
|
|
122
|
+
return {};
|
|
123
|
+
}
|
|
124
|
+
for (const key of keys) {
|
|
125
|
+
if (key !== 'assignmentId')
|
|
126
|
+
throw new ManagedSkillsRequestError('bad-request', `取技能包请求包含未知字段 '${key}'。`);
|
|
127
|
+
}
|
|
128
|
+
const rawId = record['assignmentId'];
|
|
129
|
+
if (typeof rawId !== 'string' || !ASSIGNMENT_ID_PATTERN.test(rawId.trim())) {
|
|
130
|
+
throw new ManagedSkillsRequestError('bad-request', '取技能包请求的 assignmentId 不合法。');
|
|
131
|
+
}
|
|
132
|
+
return { assignmentId: rawId.trim() };
|
|
133
|
+
}
|
|
134
|
+
/** 是否为白名单端点。 */
|
|
135
|
+
export function isManagedSkillsRpcEndpoint(endpoint) {
|
|
136
|
+
return MANAGED_SKILLS_RPC_ENDPOINTS.includes(endpoint);
|
|
137
|
+
}
|
|
138
|
+
/** 非空字符串或 undefined(清单里一个字段坏了不该让整条清单失败)。 */
|
|
139
|
+
function textOrUndefined(value) {
|
|
140
|
+
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 把网关响应里的一条分配规范化成 {@link ManagedSkillAssignment}。
|
|
144
|
+
*
|
|
145
|
+
* **结构不完整就整条丢弃**:缺了 `assignmentId` / `skillName` / `contentSha256` 的条目根本装不了
|
|
146
|
+
* ——前两个是落盘坐标,第三个是「要不要装、装对了没有」的唯一判据(少了它,客户端会拿到一个
|
|
147
|
+
* 每次都比不上的哈希,症状是「永远装不上」而不是「这次没拉到」)。丢弃时记一条日志,因为
|
|
148
|
+
* 服务端数据问题的证据在 Host 侧,客户端看不到网关响应。
|
|
149
|
+
*
|
|
150
|
+
* **其余字段不做白名单过滤**:`description` / `version` / `slug` 都是展示用的,认不认识不属于
|
|
151
|
+
* Host 的判断(技能包格式只有一种,没有 MCP 那种「transport / auth.kind 认不认识」的开放维度)。
|
|
152
|
+
*/
|
|
153
|
+
function normalizeAssignment(raw, log) {
|
|
154
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
155
|
+
return undefined;
|
|
156
|
+
const record = raw;
|
|
157
|
+
const assignmentId = textOrUndefined(record['assignmentId']);
|
|
158
|
+
const skillName = textOrUndefined(record['skillName']);
|
|
159
|
+
const contentSha256 = textOrUndefined(record['contentSha256']);
|
|
160
|
+
if (assignmentId === undefined || skillName === undefined || contentSha256 === undefined) {
|
|
161
|
+
log?.('hiwork-core: 技能清单里有一条结构不完整的分配,已跳过');
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
const revision = record['revision'];
|
|
165
|
+
const sizeBytes = record['sizeBytes'];
|
|
166
|
+
return {
|
|
167
|
+
assignmentId,
|
|
168
|
+
revision: typeof revision === 'number' && Number.isFinite(revision) ? revision : 0,
|
|
169
|
+
slug: textOrUndefined(record['slug']) ?? skillName,
|
|
170
|
+
displayName: textOrUndefined(record['displayName']) ?? skillName,
|
|
171
|
+
skillName,
|
|
172
|
+
description: textOrUndefined(record['description']) ?? '',
|
|
173
|
+
contentSha256,
|
|
174
|
+
sizeBytes: typeof sizeBytes === 'number' && Number.isFinite(sizeBytes) ? sizeBytes : 0,
|
|
175
|
+
version: textOrUndefined(record['version']) ?? '',
|
|
176
|
+
updatedAt: textOrUndefined(record['updatedAt']) ?? '',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** 任意异常 → 可展示的失败(网关 code 透传,message 消毒;其余收敛为通用失败)。 */
|
|
180
|
+
function failureOf(error) {
|
|
181
|
+
if (error instanceof ManagedSkillsRequestError)
|
|
182
|
+
return { code: error.code, message: error.message, details: {} };
|
|
183
|
+
if (error instanceof GatewayError) {
|
|
184
|
+
return { code: error.code, message: displayManagedSkillsMessage(error.code, error.message), details: {} };
|
|
185
|
+
}
|
|
186
|
+
return { code: 'internal', message: MANAGED_SKILLS_GENERIC_FAILURE_MESSAGE, details: {} };
|
|
187
|
+
}
|
|
188
|
+
/** 会话 cookie 规范化:空串与 undefined 同义(都是「当前没有会话」),与 `resolveSession` 的口径一致。 */
|
|
189
|
+
function normalizeSession(sessionCookie) {
|
|
190
|
+
return sessionCookie === undefined || sessionCookie === '' ? undefined : sessionCookie;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* 清单缓存:**只有成功响应才覆写**,失败只记原因;且缓存**按会话绑定**(见 {@link bindSession})。
|
|
194
|
+
*
|
|
195
|
+
* 「成功」的判据里含**响应形状**:200 但响应不是「带数组 `assignments` 的对象」按**取数失败**
|
|
196
|
+
* 处理(见 {@link ManagedSkillsStore.fetchOnce})——那种响应是中间层给的,不是服务端说「你没有
|
|
197
|
+
* 技能」,把它当成空清单会让客户端把本地受管技能全删。
|
|
198
|
+
*
|
|
199
|
+
* 进程内的(不落盘)是有意的:清单是「服务端此刻认什么」的投影,跨重启的旧清单没有任何价值
|
|
200
|
+
* ——重启后客户端会立刻同步一次,拿到新的比拿到旧的更安全;而且**绝不落盘**这条还保证了
|
|
201
|
+
* 「本地磁盘上不存在一份可能被误当成事实的旧清单」(受管记录归 hiwork-capabilities 的
|
|
202
|
+
* `hiwork_skills` 域,见 spec §4.3)。
|
|
203
|
+
*/
|
|
204
|
+
export class ManagedSkillsStore {
|
|
205
|
+
deps;
|
|
206
|
+
now;
|
|
207
|
+
cached;
|
|
208
|
+
lastError;
|
|
209
|
+
/** 产生 `cached` 的那个会话 cookie(只在内存里)。会话绑定的全部依据就是它。 */
|
|
210
|
+
cachedSession;
|
|
211
|
+
/**
|
|
212
|
+
* 在飞请求连同它所用的会话一起记账:并发合并**只允许发生在同一会话内**。
|
|
213
|
+
* 合并跨会话等于把 A 那次拉取的结果交给 B——`refresh` 是客户端唯一被允许用来删本地技能的那条
|
|
214
|
+
* 路(spec §6 的唯一安全不变量),这条缝不能留。
|
|
215
|
+
*/
|
|
216
|
+
inFlight;
|
|
217
|
+
constructor(deps, now = () => Date.now()) {
|
|
218
|
+
this.deps = deps;
|
|
219
|
+
this.now = now;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* 当前缓存(同步读,不发请求)。从没成功过返回 undefined;
|
|
223
|
+
* 最近一次失败会以 `stale + lastError` 标注在上一次成功的结果上。
|
|
224
|
+
*
|
|
225
|
+
* ⚠️ **本方法不做会话绑定**——它是 spec §4.5 冻结的同步读,而当前会话只能异步解析(见
|
|
226
|
+
* {@link peekForCurrentSession}),同步函数里根本判不了「这份缓存还算不算我的」。
|
|
227
|
+
* 所以模块内唯一的正确用法是:先 `await store.peekForCurrentSession()`,再(或由它内部)`peek()`。
|
|
228
|
+
* 直接调 `peek()` 只适合诊断与测试断言内存状态。
|
|
229
|
+
*/
|
|
230
|
+
peek() {
|
|
231
|
+
if (this.cached === undefined)
|
|
232
|
+
return undefined;
|
|
233
|
+
return this.lastError === undefined
|
|
234
|
+
? this.cached
|
|
235
|
+
: { ...this.cached, stale: true, lastError: this.lastError };
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* 「当前会话下的缓存」= 解析当前会话 → 剪枝 → {@link peek}。
|
|
239
|
+
*
|
|
240
|
+
* 为什么比对写在**这里**而不是 `peek()` 里:`peek()` 是 spec §4.5 冻结的**同步**接口,而
|
|
241
|
+
* 「当前是谁」只有 `deps.resolveSession()` 异步拿得到。两者凑不到一个函数里,于是分工只能是
|
|
242
|
+
* 「async 的一侧先绑会话,sync 的 `peek()` 再读」。这是本文件里唯一一处 async 前置步骤,
|
|
243
|
+
* 端点实现(`handleManagedSkillsEndpoint`)必须走它、不许绕过。
|
|
244
|
+
*/
|
|
245
|
+
async peekForCurrentSession() {
|
|
246
|
+
this.bindSession(normalizeSession(await this.deps.resolveSession()));
|
|
247
|
+
return this.peek();
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* 会话绑定:缓存只属于产生它的那个会话;当前会话不是它(含**当前没有会话**)→ 立刻丢弃。
|
|
251
|
+
*
|
|
252
|
+
* 这是刻意与 `src/managed-mcp.ts` **不同**的一处,**不许**为了「两半对齐」改回去:
|
|
253
|
+
* - 技能这半的 {@link clear} 是 spec §4.5 冻结要求提供的接口,却**没有任何调用方**——端点白名单
|
|
254
|
+
* 只有 `snapshot`/`refresh`/`package`,客户端够不到 Host 的内存,Host 自己也没有登出钩子;
|
|
255
|
+
* 而「靠一个没人调的钩子保证安全」正是**已经失败过一次**的做法(MCP 那条链路里同样的
|
|
256
|
+
* `clear()` 也悬着),不能把安全不变量挂在它上面。
|
|
257
|
+
* - 绑会话**自带**:不依赖任何调用方、不依赖谁记得清缓存,且方向更严——登出后(当前会话为空)
|
|
258
|
+
* 与换账号后都不会再吐出上一个人的清单。更严在这里恒等于更安全。
|
|
259
|
+
*
|
|
260
|
+
* 它也**不**触碰「失败不覆写缓存」那条铁律:剪枝的触发条件是**会话变了**,不是「这一次拉取
|
|
261
|
+
* 失败了」。失败路径仍然原样保留本会话上一次成功的清单(`stale + lastError`)。
|
|
262
|
+
*/
|
|
263
|
+
bindSession(current) {
|
|
264
|
+
if (current === this.cachedSession)
|
|
265
|
+
return;
|
|
266
|
+
this.cached = undefined;
|
|
267
|
+
this.cachedSession = undefined;
|
|
268
|
+
// 失败记录也是**按会话的**:换了人(或登出)之后,上一个人的 lastError 与这一份都不该
|
|
269
|
+
// 继续挂在账上——否则新的 `snapshot` 会把别人的失败当作自己的「最近一次失败」展示。
|
|
270
|
+
this.lastError = undefined;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* 去网关拉一次清单并更新缓存。失败时抛异常,且**本会话的缓存原样保留**。
|
|
274
|
+
*
|
|
275
|
+
* 并发调用合并成一次请求:后台预热与客户端手动同步撞在一起时不必打两遍网关。
|
|
276
|
+
* 合并**只在同一会话内**——两个会话合并到一起,就等于把 A 的结果发给 B。
|
|
277
|
+
*/
|
|
278
|
+
async load() {
|
|
279
|
+
const session = normalizeSession(await this.deps.resolveSession());
|
|
280
|
+
const inFlight = this.inFlight;
|
|
281
|
+
if (inFlight !== undefined && inFlight.session === session)
|
|
282
|
+
return await inFlight.task;
|
|
283
|
+
const task = this.fetchOnce(session);
|
|
284
|
+
this.inFlight = { session, task };
|
|
285
|
+
try {
|
|
286
|
+
return await task;
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
// 只有自己那次还在账上才清:更晚发起的(另一个会话的)请求不该被这一笔收尾抹掉。
|
|
290
|
+
if (this.inFlight?.task === task)
|
|
291
|
+
this.inFlight = undefined;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* 清空内存清单(登出 / 换账号调用)。
|
|
296
|
+
*
|
|
297
|
+
* ⚠️ **当前没有任何调用方**:这是 spec §4.5 冻结要求提供的接口(客户端半边的同名方法有调用方,
|
|
298
|
+
* Host 这半没有),而它的用途已经被**会话绑定**覆盖——缓存按会话剪枝,登出 / 换账号后再也吐不出
|
|
299
|
+
* 上一个人的清单(见 {@link bindSession})。保留它是为了不改冻结接口;真要删得先改 spec §4.5。
|
|
300
|
+
* 冻结接口里的成员不要悄悄删掉,哪怕它暂时是死的。
|
|
301
|
+
*/
|
|
302
|
+
clear() {
|
|
303
|
+
this.cached = undefined;
|
|
304
|
+
this.lastError = undefined;
|
|
305
|
+
this.cachedSession = undefined;
|
|
306
|
+
}
|
|
307
|
+
async fetchOnce(sessionCookie) {
|
|
308
|
+
try {
|
|
309
|
+
if (sessionCookie === undefined) {
|
|
310
|
+
throw new GatewayError('SESSION_EXPIRED', MANAGED_SKILLS_SESSION_EXPIRED_MESSAGE);
|
|
311
|
+
}
|
|
312
|
+
const gateway = this.deps.gateway();
|
|
313
|
+
if (gateway === undefined) {
|
|
314
|
+
throw new GatewayError('GATEWAY_UNREACHABLE', '网关地址未配置,请先在「网关模型」里完成登录');
|
|
315
|
+
}
|
|
316
|
+
const view = await gateway.getMySkillAssignments(sessionCookie);
|
|
317
|
+
// 200 + 能解析的 JSON,但**不是「带数组 assignments 的对象」**⇒ 按取数失败处理,绝不
|
|
318
|
+
// 当成「成功拉到的空清单」。这类响应只可能来自夹在中间的层(反代维护页 / SSO 门户 /
|
|
319
|
+
// 任何在 200 上回自己那份 JSON 的东西)——服务端这条端点的 200 形状是冻结契约(spec §4.1)。
|
|
320
|
+
// 读成空清单就是灾难:客户端据此把本地受管技能**全删**(spec §6 的唯一安全不变量)。
|
|
321
|
+
// 逐条 `normalizeAssignment` 的容错不在此列(丢一条坏**条目**是收紧方向),这里丢的是整份响应。
|
|
322
|
+
if (typeof view !== 'object' || view === null || !Array.isArray(view.assignments)) {
|
|
323
|
+
this.deps.log?.('hiwork-core: 技能清单响应不是带 assignments 数组的对象(可能的中间层),按取数失败处理');
|
|
324
|
+
throw new GatewayError('GATEWAY_UNREACHABLE', MANAGED_SKILLS_GENERIC_FAILURE_MESSAGE);
|
|
325
|
+
}
|
|
326
|
+
const assignments = [];
|
|
327
|
+
for (const item of view.assignments) {
|
|
328
|
+
const normalized = normalizeAssignment(item, this.deps.log);
|
|
329
|
+
if (normalized !== undefined)
|
|
330
|
+
assignments.push(normalized);
|
|
331
|
+
}
|
|
332
|
+
const snapshot = {
|
|
333
|
+
revision: typeof view.revision === 'number' && Number.isFinite(view.revision) ? view.revision : 0,
|
|
334
|
+
fetchedAt: new Date(this.now()).toISOString(),
|
|
335
|
+
assignments,
|
|
336
|
+
stale: false,
|
|
337
|
+
};
|
|
338
|
+
// 成功才覆写,并**连同产生它的会话**一起记账:会话绑定(bindSession)之后要靠这一行。
|
|
339
|
+
// 迟到的写回(旧会话的请求在换账号之后才返回)不构成漏洞:它只会把 cachedSession 记回旧会话,
|
|
340
|
+
// 而任何一次读取都要先过 bindSession——不匹配就当场丢弃,永远到不了调用方手里。
|
|
341
|
+
this.cached = snapshot;
|
|
342
|
+
this.cachedSession = sessionCookie;
|
|
343
|
+
this.lastError = undefined;
|
|
344
|
+
return snapshot;
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
this.lastError = failureOf(error);
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/** 取消判定(用函数包一层,避免 TS 把属性窄化固化到 `await` 之后)。 */
|
|
353
|
+
function isAborted(signal) {
|
|
354
|
+
return signal?.aborted === true;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* 处理一次端点调用:返回 DSH Connection 的结果信封,**永不抛异常**。
|
|
358
|
+
* 导出以便单测直接驱动(无需伪造 `ctx.connection`)。
|
|
359
|
+
*/
|
|
360
|
+
export async function handleManagedSkillsEndpoint(endpoint, rawPayload, deps, store, signal) {
|
|
361
|
+
try {
|
|
362
|
+
if (isAborted(signal))
|
|
363
|
+
return failure('cancelled', '技能清单请求已取消。');
|
|
364
|
+
if (!isManagedSkillsRpcEndpoint(endpoint)) {
|
|
365
|
+
throw new ManagedSkillsRequestError('bad-request', `未知的技能清单端点 '${endpoint}'`);
|
|
366
|
+
}
|
|
367
|
+
const payload = parseManagedSkillsPayload(endpoint, rawPayload);
|
|
368
|
+
if (endpoint === 'package') {
|
|
369
|
+
const assignmentId = payload.assignmentId;
|
|
370
|
+
if (assignmentId === undefined)
|
|
371
|
+
throw new ManagedSkillsRequestError('bad-request', '取技能包请求缺少 assignmentId。');
|
|
372
|
+
// 包体**不进缓存**:它是一条分配的内容,不是「服务端此刻认什么」;缓存下来等于把
|
|
373
|
+
// 每次同步装过的所有技能包都钉在 Host 内存里(base64 后每条约 5 MiB)。
|
|
374
|
+
return await handlePackage(assignmentId, deps, signal);
|
|
375
|
+
}
|
|
376
|
+
// snapshot:有缓存就直接给(可能是 stale),冷启动才走一次网络。
|
|
377
|
+
// 注意这里**没有**直接用 `store.peek()`:spec §4.5 的 `peek()` 是同步读、拿不到当前会话,
|
|
378
|
+
// 而缓存必须按会话绑定(登出 / 换账号后不许再吐出上一个人的清单),所以走 async 前置
|
|
379
|
+
// `peekForCurrentSession()`——它先解析当前 cookie 做一次同步剪枝,再返回缓存。两个端点都走它,
|
|
380
|
+
// 于是 `refresh` 也会顺手剪掉已经不匹配的缓存(拉失败时也不会留着别人的旧清单)。
|
|
381
|
+
const cached = await store.peekForCurrentSession();
|
|
382
|
+
if (endpoint === 'snapshot' && cached !== undefined)
|
|
383
|
+
return { ok: true, value: cached };
|
|
384
|
+
return { ok: true, value: await store.load() };
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
deps.log?.(`hiwork-core: 技能清单 RPC '${endpoint}' 失败(${error instanceof Error ? error.name : typeof error})`);
|
|
388
|
+
return { ok: false, error: failureOf(error) };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* `package`:把某一条分配的 ZIP 字节 base64 后交给客户端。
|
|
393
|
+
*
|
|
394
|
+
* 分工:**只有 Host 拿得到字节**(会话 cookie 在这里,且绝不外流),客户端拿到的是 base64
|
|
395
|
+
* 字符串,由它自己解码、自己算 sha256 与清单里的 `contentSha256` 比对(spec §6 的完整性那一行)。
|
|
396
|
+
* 校验失败的那条不落盘、报 `failed`,旧版本照常可用。
|
|
397
|
+
*/
|
|
398
|
+
async function handlePackage(assignmentId, deps, signal) {
|
|
399
|
+
const sessionCookie = await deps.resolveSession();
|
|
400
|
+
if (sessionCookie === undefined || sessionCookie === '') {
|
|
401
|
+
return failure('SESSION_EXPIRED', MANAGED_SKILLS_SESSION_EXPIRED_MESSAGE);
|
|
402
|
+
}
|
|
403
|
+
const gateway = deps.gateway();
|
|
404
|
+
if (gateway === undefined) {
|
|
405
|
+
return failure('GATEWAY_UNREACHABLE', '网关地址未配置,请先在「网关模型」里完成登录');
|
|
406
|
+
}
|
|
407
|
+
if (isAborted(signal))
|
|
408
|
+
return failure('cancelled', '技能清单请求已取消。');
|
|
409
|
+
const pkg = await gateway.getMySkillPackage(sessionCookie, assignmentId);
|
|
410
|
+
const bytes = pkg?.bytes;
|
|
411
|
+
const skillName = textOrUndefined(pkg?.skillName);
|
|
412
|
+
const contentSha256 = textOrUndefined(pkg?.contentSha256);
|
|
413
|
+
// 一个空包体或没有身份的包体都不是技能包:在 Host 就拦住,别让它变成客户端那边一次
|
|
414
|
+
// 看不懂的「哈希对不上」。这两种情况只可能来自服务端数据问题,所以收敛成通用失败。
|
|
415
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0 || skillName === undefined || contentSha256 === undefined) {
|
|
416
|
+
deps.log?.('hiwork-core: 取技能包收到无效响应(缺 skillName / contentSha256 或空包体)');
|
|
417
|
+
return failure('internal', MANAGED_SKILLS_GENERIC_FAILURE_MESSAGE);
|
|
418
|
+
}
|
|
419
|
+
// 大小闸:base64 之后约 5.3 MiB,是本频道唯一一次大对象传输(spec §8 R8)。
|
|
420
|
+
if (bytes.byteLength > MAX_MANAGED_SKILL_ARCHIVE_BYTES) {
|
|
421
|
+
deps.log?.('hiwork-core: 技能包超过大小上限,已拒绝搬运');
|
|
422
|
+
return failure(MANAGED_SKILLS_PACKAGE_TOO_LARGE_CODE, MANAGED_SKILLS_PACKAGE_TOO_LARGE_MESSAGE);
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
ok: true,
|
|
426
|
+
value: {
|
|
427
|
+
assignmentId,
|
|
428
|
+
skillName,
|
|
429
|
+
contentSha256,
|
|
430
|
+
// Host 半边只跑在 Node 侧(`tsconfig.build.json` 排除 `src/client/**`,client bundle
|
|
431
|
+
// 不 import 本文件),所以直接用 Buffer;客户端那半边的解码在 Task 11 的文件里。
|
|
432
|
+
archiveBase64: Buffer.from(bytes).toString('base64'),
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
function failure(code, message) {
|
|
437
|
+
return { ok: false, error: { code, message, details: {} } };
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* 注册 `/hiwork-managed-skills` 频道,返回异步 disposer(交给 `ctx.effect` 管理生命周期)。
|
|
441
|
+
*/
|
|
442
|
+
export function registerManagedSkillsRpc(ctx, deps, store) {
|
|
443
|
+
const handler = (endpoint, payload, signal) => handleManagedSkillsEndpoint(endpoint, payload, deps, store, signal);
|
|
444
|
+
const remove = ctx.connection.rpc.handle(MANAGED_SKILLS_RPC_CHANNEL, handler, { authority: 'loopback' });
|
|
445
|
+
return async () => { await remove(); };
|
|
446
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 网关集中下发的 OnlyOffice 集成凭据(host 侧 cordis 服务 `hiworkOfficeCredentials`)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:hiwork-office 插件原先要求每台终端在设置里手工粘贴 Document Server 的 JWT
|
|
5
|
+
* 密钥——密钥靠人肉传播(聊天记录/文档里转发)、轮换无通道、谁在用无法审计
|
|
6
|
+
* (2026-09-16 查证:DS 侧密钥只是部署机 `.env` 里的一行 `JWT_SECRET`,没有任何管理
|
|
7
|
+
* 能力)。本模块把「密钥从哪来」收敛到网关:hiwork-office 的 Host 半边按登录态经本
|
|
8
|
+
* 服务取 `{ documentServerUrl, jwtSecret }`,终端零配置。
|
|
9
|
+
*
|
|
10
|
+
* 设计要点(与 `src/managed-mcp.ts` / `src/daily-rpc.ts` 同构):
|
|
11
|
+
* - **网关会话 cookie 绝不离开 Host**:只在 `resolveSession()` 内部出现;
|
|
12
|
+
* - **本模块不缓存**:调用方(hiwork-office)按自己的 TTL 决定何时取——密钥轮换后
|
|
13
|
+
* 它的下一次拉取自然拿到新值,不存在两层缓存互相打架;
|
|
14
|
+
* - **失败不抛异常**,返回稳定 `{ ok: false, code, message }`:message 经消毒,
|
|
15
|
+
* 不含 baseURL / HTTP 状态码等实现细节,可直接展示给用户;
|
|
16
|
+
* - 服务经 `ctx.provide` 提供给同进程的其他插件,hiwork-office 用 `ctx.get` 探测
|
|
17
|
+
* (老 core 上它是 undefined,插件回退本地手工配置——独立安装场景)。
|
|
18
|
+
*
|
|
19
|
+
* 这是「密钥下发」的 MVP(方案一):密钥仍随登录态落到终端,但不再靠人肉传播。
|
|
20
|
+
* 后续演进(方案二:网关代签)不改变本服务的**形状**——消费方拿到的仍是一份
|
|
21
|
+
* 可用的集成配置,只是内容可能从共享密钥换成短期凭证,两端无需改结构。
|
|
22
|
+
*
|
|
23
|
+
* 本模块不 import cordis 运行时:provide 走结构化的 {@link OfficeCredentialsContext}
|
|
24
|
+
* 端口,调用方(index.ts)传真实 ctx;单测直接伪造。
|
|
25
|
+
*/
|
|
26
|
+
import { GatewayError } from "./ai-router-client.js";
|
|
27
|
+
/** cordis 服务名(hiwork-office 的 Host 半边按此名探测)。 */
|
|
28
|
+
export const OFFICE_CREDENTIALS_SERVICE = 'hiworkOfficeCredentials';
|
|
29
|
+
/** 各稳定错误码的用户可见文案(网关 message 含实现细节时用它兜底)。 */
|
|
30
|
+
const OFFICE_MESSAGE_FALLBACK = {
|
|
31
|
+
SESSION_EXPIRED: '登录状态已过期,请重新登录',
|
|
32
|
+
GATEWAY_UNREACHABLE: '网关暂时不可用,请稍后重试',
|
|
33
|
+
OFFICE_NOT_CONFIGURED: '本部署尚未配置 HiWork Office 文档办公集成,请联系管理员',
|
|
34
|
+
};
|
|
35
|
+
/** 通用失败文案:不暴露任何网关细节。 */
|
|
36
|
+
export const OFFICE_CREDENTIALS_GENERIC_FAILURE_MESSAGE = 'HiWork Office 集成配置暂时无法获取,请稍后重试';
|
|
37
|
+
/** 未登录 / 会话失效的统一文案(与用量、每日一句、MCP 三个频道同口径)。 */
|
|
38
|
+
export const OFFICE_CREDENTIALS_SESSION_EXPIRED_MESSAGE = '登录状态已过期,请重新登录';
|
|
39
|
+
/**
|
|
40
|
+
* 网关 message 消毒(与 `usage-rpc.ts` 的 `displayGatewayMessage` 同实现):
|
|
41
|
+
* 含 baseURL / HTTP 状态码 / URL 的原始消息一律换成稳定文案,其余(网关自己写好的
|
|
42
|
+
* 中文业务文案)原样保留。code 不受影响,始终原样透传。
|
|
43
|
+
*/
|
|
44
|
+
export function displayOfficeGatewayMessage(code, message) {
|
|
45
|
+
const text = typeof message === 'string' ? message.trim() : '';
|
|
46
|
+
if (text === '')
|
|
47
|
+
return OFFICE_MESSAGE_FALLBACK[code] ?? OFFICE_CREDENTIALS_GENERIC_FAILURE_MESSAGE;
|
|
48
|
+
const leaksImplementationDetail = /https?:\/\//i.test(text)
|
|
49
|
+
|| /\bHTTP\s*\d{3}\b/i.test(text)
|
|
50
|
+
|| /\bbaseURL\b/i.test(text);
|
|
51
|
+
if (leaksImplementationDetail)
|
|
52
|
+
return OFFICE_MESSAGE_FALLBACK[code] ?? OFFICE_CREDENTIALS_GENERIC_FAILURE_MESSAGE;
|
|
53
|
+
return text;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 凭据取数(无缓存;缓存策略是消费方的事)。
|
|
57
|
+
*/
|
|
58
|
+
export class OfficeCredentialsBroker {
|
|
59
|
+
deps;
|
|
60
|
+
constructor(deps) {
|
|
61
|
+
this.deps = deps;
|
|
62
|
+
}
|
|
63
|
+
async resolve() {
|
|
64
|
+
try {
|
|
65
|
+
const sessionCookie = await this.deps.resolveSession();
|
|
66
|
+
if (sessionCookie === undefined || sessionCookie === '') {
|
|
67
|
+
return { ok: false, code: 'SESSION_EXPIRED', message: OFFICE_CREDENTIALS_SESSION_EXPIRED_MESSAGE };
|
|
68
|
+
}
|
|
69
|
+
const gateway = this.deps.gateway();
|
|
70
|
+
if (gateway === undefined) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
code: 'GATEWAY_UNREACHABLE',
|
|
74
|
+
message: '网关地址未配置,请先在「网关模型」里完成登录',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const view = await gateway.getMyOfficeConfig(sessionCookie);
|
|
78
|
+
const documentServerUrl = typeof view?.documentServerUrl === 'string' ? view.documentServerUrl.trim() : '';
|
|
79
|
+
const jwtSecret = typeof view?.jwtSecret === 'string' ? view.jwtSecret.trim() : '';
|
|
80
|
+
if (documentServerUrl === '' || jwtSecret === '') {
|
|
81
|
+
// 网关 200 却缺字段 = 契约破坏:按「不可用」处理,绝不把半份配置交给终端。
|
|
82
|
+
this.deps.log?.('hiwork-core: 网关返回的 office-config 不完整(缺 documentServerUrl 或 jwtSecret)');
|
|
83
|
+
return { ok: false, code: 'GATEWAY_UNREACHABLE', message: OFFICE_CREDENTIALS_GENERIC_FAILURE_MESSAGE };
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
value: {
|
|
88
|
+
documentServerUrl,
|
|
89
|
+
jwtSecret,
|
|
90
|
+
fetchedAt: (this.deps.now?.() ?? new Date()).toISOString(),
|
|
91
|
+
relayEnabled: view?.relayEnabled === true,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error instanceof GatewayError) {
|
|
97
|
+
// code 原样透传(稳定契约),message 消毒后再交给消费方。
|
|
98
|
+
return { ok: false, code: error.code, message: displayOfficeGatewayMessage(error.code, error.message) };
|
|
99
|
+
}
|
|
100
|
+
this.deps.log?.(`hiwork-core: HiWork Office 凭据获取失败(${error instanceof Error ? error.name : typeof error})`);
|
|
101
|
+
return { ok: false, code: 'internal', message: OFFICE_CREDENTIALS_GENERIC_FAILURE_MESSAGE };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 提供 host 侧服务 `hiworkOfficeCredentials`;返回 disposer(交给 `ctx.effect` 管理
|
|
107
|
+
* 生命周期,插件卸载时服务随 fiber 一并注销)。
|
|
108
|
+
*/
|
|
109
|
+
export function provideOfficeCredentials(ctx, deps) {
|
|
110
|
+
const service = new OfficeCredentialsBroker(deps);
|
|
111
|
+
const dispose = ctx.provide(OFFICE_CREDENTIALS_SERVICE, service);
|
|
112
|
+
return typeof dispose === 'function' ? dispose : () => { };
|
|
113
|
+
}
|