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.
@@ -0,0 +1,334 @@
1
+ /**
2
+ * `/hiwork-managed-mcp` loopback RPC 的 Host 实现:管理员下发的 MCP 清单 + 换令牌用的短期断言。
3
+ *
4
+ * 设计要点(与 `src/usage-rpc.ts` / `src/daily-rpc.ts` 同构,差异都在下面写清):
5
+ * - 只注册一个 channel,endpoint 白名单在 {@link MANAGED_MCP_RPC_ENDPOINTS},未知
6
+ * endpoint 直接 `bad-request`;
7
+ * - payload 严格校验:`snapshot` / `refresh` 只接受空对象,`assertion` 只接受
8
+ * `{ assignmentId }`(这是唯一一处客户端可控输入,形状卡死在 Host);
9
+ * - **网关会话 cookie 绝不离开 Host**:调用方拿到的是清单与断言,cookie 只在
10
+ * `resolveSession()` 内部出现;
11
+ * - **本频道不换令牌、不持有令牌**。断言只是一张 120 秒的门票,拿它去 MCP 服务的
12
+ * `/mcp-credential-exchange` 换 per-user 令牌是客户端(hiwork-capabilities)的事。
13
+ * 这样 ai-hub 与 hiwork-core 全程经手不了任何 MCP 凭据,被攻破也拿不到东西。
14
+ * - **拉清单失败绝不清空上一次成功的清单**:客户端只有在拿到一份「成功拉到的清单」
15
+ * 时才允许删除本地受管连接,所以失败必须以「保留旧值 + 标注失败原因」的形状返回,
16
+ * 否则一次网络抖动就会让员工端把自己卸载干净。
17
+ *
18
+ * 本模块不 import cordis 运行时,只依赖结构化的 {@link ManagedMcpRpcContext} 端口,
19
+ * 便于单测直接伪造。
20
+ */
21
+ import { GatewayError, } from "./ai-router-client.js";
22
+ /** RPC 频道;必须与 client 半边 `src/client/managed-mcp.ts` 的 `MANAGED_MCP_CHANNEL` 一致(由测试锁定)。 */
23
+ export const MANAGED_MCP_RPC_CHANNEL = '/hiwork-managed-mcp';
24
+ /**
25
+ * 端点白名单。
26
+ *
27
+ * `snapshot` 是「你现在有什么」(有缓存就直接给,冷启动才拉一次),`refresh` 是
28
+ * 「现在去拉一次」。分两个端点的理由:客户端的同步策略(启动 / 30 分钟 / 打开连接器页
29
+ * 且 >5min stale)由它自己定,Host 不该替它决定什么时候必须联网。
30
+ */
31
+ export const MANAGED_MCP_RPC_ENDPOINTS = ['snapshot', 'refresh', 'assertion'];
32
+ /**
33
+ * Host 侧后台预热间隔:30 分钟(与客户端同步节奏同量级)。
34
+ * 它**不是**权威节奏——只为让客户端首次 `snapshot` 不必等一次网络往返。
35
+ */
36
+ export const MANAGED_MCP_REFRESH_INTERVAL_MS = 30 * 60_000;
37
+ /** 请求侧错误(只有 `bad-request`:未知端点 / 非法 payload)。 */
38
+ export class ManagedMcpRequestError extends Error {
39
+ code;
40
+ constructor(code, message) {
41
+ super(message);
42
+ this.code = code;
43
+ this.name = 'ManagedMcpRequestError';
44
+ }
45
+ }
46
+ /** 各稳定错误码的用户可见文案(网关 message 含实现细节时用它兜底)。 */
47
+ const MANAGED_MCP_MESSAGE_FALLBACK = {
48
+ SESSION_EXPIRED: '登录状态已过期,请重新登录',
49
+ GATEWAY_UNREACHABLE: '网关暂时不可用,请稍后重试',
50
+ MCP_ASSIGNMENT_GONE: '这条分配已失效,稍后同步会重新核对',
51
+ MCP_NOT_DELIVERABLE: '该 MCP 服务当前不可下发,请联系管理员',
52
+ MCP_ASSERTION_DISABLED: '本部署未启用 MCP 断言签发,请联系管理员',
53
+ NO_KEY_ASSIGNED: '当前账号尚未被分配虚拟 KEY,请联系管理员',
54
+ };
55
+ /** 通用失败文案:不暴露任何网关细节。 */
56
+ export const MANAGED_MCP_GENERIC_FAILURE_MESSAGE = 'MCP 清单暂时无法获取,请稍后重试';
57
+ /** 未登录 / 会话失效的统一文案(与用量、每日一句两个频道同口径)。 */
58
+ export const MANAGED_MCP_SESSION_EXPIRED_MESSAGE = '登录状态已过期,请重新登录';
59
+ /**
60
+ * 网关 message 消毒(与 `usage-rpc.ts` 的 `displayGatewayMessage` 同实现):
61
+ * 含 baseURL / HTTP 状态码 / URL 的原始消息一律换成稳定文案,其余(网关自己写好的
62
+ * 中文业务文案)原样保留。code 不受影响,始终原样透传。
63
+ */
64
+ export function displayManagedMcpMessage(code, message) {
65
+ const text = typeof message === 'string' ? message.trim() : '';
66
+ if (text === '')
67
+ return MANAGED_MCP_MESSAGE_FALLBACK[code] ?? MANAGED_MCP_GENERIC_FAILURE_MESSAGE;
68
+ const leaksImplementationDetail = /https?:\/\//i.test(text)
69
+ || /\bHTTP\s*\d{3}\b/i.test(text)
70
+ || /\bbaseURL\b/i.test(text);
71
+ if (leaksImplementationDetail)
72
+ return MANAGED_MCP_MESSAGE_FALLBACK[code] ?? MANAGED_MCP_GENERIC_FAILURE_MESSAGE;
73
+ return text;
74
+ }
75
+ /**
76
+ * `assertion` 的 assignmentId 形状:只放行 uuid 用的字符集与长度(1–64)。
77
+ * 这是全频道唯一一处客户端可控输入,卡在 Host 比卡在网关早——一个带空格 / 换行 /
78
+ * 百分号的串没有任何合法来源,直接 `bad-request`。
79
+ */
80
+ const ASSIGNMENT_ID_PATTERN = /^[0-9a-fA-F-]{1,64}$/;
81
+ /**
82
+ * payload 校验:`snapshot` / `refresh` 只放行空对象,`assertion` 只放行
83
+ * `{ assignmentId }`;其余字段 / 类型不符一律 `bad-request`。
84
+ */
85
+ export function parseManagedMcpPayload(endpoint, raw) {
86
+ if (raw === undefined || raw === null) {
87
+ if (endpoint === 'assertion')
88
+ throw new ManagedMcpRequestError('bad-request', '取断言请求缺少 assignmentId。');
89
+ return {};
90
+ }
91
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
92
+ throw new ManagedMcpRequestError('bad-request', 'MCP 清单请求参数不合法。');
93
+ }
94
+ const record = raw;
95
+ const keys = Object.keys(record);
96
+ if (endpoint !== 'assertion') {
97
+ // snapshot / refresh 无任何入参:出现任何字段都是客户端实现缺陷。
98
+ if (keys.length > 0)
99
+ throw new ManagedMcpRequestError('bad-request', `MCP 清单请求包含未知字段 '${keys[0]}'。`);
100
+ return {};
101
+ }
102
+ for (const key of keys) {
103
+ if (key !== 'assignmentId')
104
+ throw new ManagedMcpRequestError('bad-request', `取断言请求包含未知字段 '${key}'。`);
105
+ }
106
+ const rawId = record['assignmentId'];
107
+ if (typeof rawId !== 'string' || !ASSIGNMENT_ID_PATTERN.test(rawId.trim())) {
108
+ throw new ManagedMcpRequestError('bad-request', '取断言请求的 assignmentId 不合法。');
109
+ }
110
+ return { assignmentId: rawId.trim() };
111
+ }
112
+ /** 是否为白名单端点。 */
113
+ export function isManagedMcpRpcEndpoint(endpoint) {
114
+ return MANAGED_MCP_RPC_ENDPOINTS.includes(endpoint);
115
+ }
116
+ /** 非空字符串或 undefined(清单里一个字段坏了不该让整条清单失败)。 */
117
+ function textOrUndefined(value) {
118
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
119
+ }
120
+ /**
121
+ * 把网关响应里的一条分配规范化成 {@link ManagedMcpAssignment}。
122
+ *
123
+ * **结构不完整就整条丢弃**(缺 id / serverName / url / auth.mintUrl 的条目装不了,
124
+ * 留着只会在下游变成一次诡异的失败),丢弃时记一条日志——服务端数据问题的证据在
125
+ * Host 侧,客户端看不到网关响应。
126
+ *
127
+ * **但 `transport` 与 `auth.kind` 不做白名单过滤**:认不认识的判断属于连接器域
128
+ * (hiwork-capabilities 知道自己能装什么),它要按「不认识的 kind 跳过并报冲突」的
129
+ * 契约把问题报到界面上;Host 在这里悄悄丢掉,冲突就永远不会被人看见。
130
+ */
131
+ function normalizeAssignment(raw, log) {
132
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
133
+ return undefined;
134
+ const record = raw;
135
+ const assignmentId = textOrUndefined(record['assignmentId']);
136
+ const serverName = textOrUndefined(record['serverName']);
137
+ const url = textOrUndefined(record['url']);
138
+ const transport = textOrUndefined(record['transport']);
139
+ const authRaw = record['auth'];
140
+ const auth = typeof authRaw === 'object' && authRaw !== null && !Array.isArray(authRaw)
141
+ ? authRaw
142
+ : undefined;
143
+ const kind = textOrUndefined(auth?.['kind']);
144
+ const mintUrl = textOrUndefined(auth?.['mintUrl']);
145
+ if (assignmentId === undefined || serverName === undefined || url === undefined
146
+ || transport === undefined || kind === undefined || mintUrl === undefined) {
147
+ log?.('hiwork-core: MCP 清单里有一条结构不完整的分配,已跳过');
148
+ return undefined;
149
+ }
150
+ const revision = record['revision'];
151
+ return {
152
+ assignmentId,
153
+ revision: typeof revision === 'number' && Number.isFinite(revision) ? revision : 0,
154
+ slug: textOrUndefined(record['slug']) ?? serverName,
155
+ displayName: textOrUndefined(record['displayName']) ?? serverName,
156
+ serverName,
157
+ transport,
158
+ url,
159
+ extraHeaders: stringMap(record['extraHeaders']),
160
+ auth: { kind, mintUrl, revokeUrl: textOrUndefined(auth?.['revokeUrl']) ?? '' },
161
+ updatedAt: textOrUndefined(record['updatedAt']) ?? '',
162
+ };
163
+ }
164
+ /** 头映射:只保留值为字符串的键(其余静默丢弃,头值只能是字符串)。 */
165
+ function stringMap(raw) {
166
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
167
+ return {};
168
+ const out = {};
169
+ for (const [key, value] of Object.entries(raw)) {
170
+ if (typeof value === 'string')
171
+ out[key] = value;
172
+ }
173
+ return out;
174
+ }
175
+ /** 任意异常 → 可展示的失败(网关 code 透传,message 消毒;其余收敛为通用失败)。 */
176
+ function failureOf(error) {
177
+ if (error instanceof ManagedMcpRequestError)
178
+ return { code: error.code, message: error.message, details: {} };
179
+ if (error instanceof GatewayError) {
180
+ return { code: error.code, message: displayManagedMcpMessage(error.code, error.message), details: {} };
181
+ }
182
+ return { code: 'internal', message: MANAGED_MCP_GENERIC_FAILURE_MESSAGE, details: {} };
183
+ }
184
+ /**
185
+ * 清单缓存:**只有成功响应才覆写**,失败只记原因。
186
+ *
187
+ * 进程内的(不落盘)是有意的:清单是「服务端此刻认什么」的投影,跨重启的旧清单
188
+ * 没有任何价值——重启后客户端会立刻同步一次,拿到新的比拿到旧的更安全。
189
+ */
190
+ export class ManagedMcpStore {
191
+ deps;
192
+ now;
193
+ cached;
194
+ lastError;
195
+ inFlight;
196
+ constructor(deps, now = () => Date.now()) {
197
+ this.deps = deps;
198
+ this.now = now;
199
+ }
200
+ /**
201
+ * 当前缓存(同步读,不发请求)。从没成功过返回 undefined;
202
+ * 最近一次失败会以 `stale + lastError` 标注在上一次成功的结果上。
203
+ */
204
+ peek() {
205
+ if (this.cached === undefined)
206
+ return undefined;
207
+ return this.lastError === undefined
208
+ ? this.cached
209
+ : { ...this.cached, stale: true, lastError: this.lastError };
210
+ }
211
+ /**
212
+ * 去网关拉一次清单并更新缓存。失败时抛异常,且**缓存原样保留**。
213
+ *
214
+ * 并发调用合并成一次请求:后台预热与客户端手动同步撞在一起时不必打两遍网关。
215
+ */
216
+ async load() {
217
+ if (this.inFlight !== undefined)
218
+ return await this.inFlight;
219
+ const task = this.fetchOnce();
220
+ this.inFlight = task;
221
+ try {
222
+ return await task;
223
+ }
224
+ finally {
225
+ this.inFlight = undefined;
226
+ }
227
+ }
228
+ async fetchOnce() {
229
+ try {
230
+ const sessionCookie = await this.deps.resolveSession();
231
+ if (sessionCookie === undefined || sessionCookie === '') {
232
+ throw new GatewayError('SESSION_EXPIRED', MANAGED_MCP_SESSION_EXPIRED_MESSAGE);
233
+ }
234
+ const gateway = this.deps.gateway();
235
+ if (gateway === undefined) {
236
+ throw new GatewayError('GATEWAY_UNREACHABLE', '网关地址未配置,请先在「网关模型」里完成登录');
237
+ }
238
+ const view = await gateway.getMyMcpAssignments(sessionCookie);
239
+ const assignments = [];
240
+ for (const item of Array.isArray(view?.assignments) ? view.assignments : []) {
241
+ const normalized = normalizeAssignment(item, this.deps.log);
242
+ if (normalized !== undefined)
243
+ assignments.push(normalized);
244
+ }
245
+ const snapshot = {
246
+ revision: typeof view?.revision === 'number' && Number.isFinite(view.revision) ? view.revision : 0,
247
+ fetchedAt: new Date(this.now()).toISOString(),
248
+ assignments,
249
+ stale: false,
250
+ };
251
+ this.cached = snapshot;
252
+ this.lastError = undefined;
253
+ return snapshot;
254
+ }
255
+ catch (error) {
256
+ this.lastError = failureOf(error);
257
+ throw error;
258
+ }
259
+ }
260
+ }
261
+ /** 取消判定(用函数包一层,避免 TS 把属性窄化固化到 `await` 之后)。 */
262
+ function isAborted(signal) {
263
+ return signal?.aborted === true;
264
+ }
265
+ /**
266
+ * 处理一次端点调用:返回 DSH Connection 的结果信封,**永不抛异常**。
267
+ * 导出以便单测直接驱动(无需伪造 `ctx.connection`)。
268
+ */
269
+ export async function handleManagedMcpEndpoint(endpoint, rawPayload, deps, store, signal) {
270
+ try {
271
+ if (isAborted(signal))
272
+ return failure('cancelled', 'MCP 清单请求已取消。');
273
+ if (!isManagedMcpRpcEndpoint(endpoint)) {
274
+ throw new ManagedMcpRequestError('bad-request', `未知的 MCP 清单端点 '${endpoint}'`);
275
+ }
276
+ const payload = parseManagedMcpPayload(endpoint, rawPayload);
277
+ if (endpoint === 'assertion') {
278
+ const assignmentId = payload.assignmentId;
279
+ if (assignmentId === undefined)
280
+ throw new ManagedMcpRequestError('bad-request', '取断言请求缺少 assignmentId。');
281
+ return await handleAssertion(assignmentId, deps, signal);
282
+ }
283
+ // snapshot:有缓存就直接给(可能是 stale),冷启动才走一次网络。
284
+ const cached = store.peek();
285
+ if (endpoint === 'snapshot' && cached !== undefined)
286
+ return { ok: true, value: cached };
287
+ return { ok: true, value: await store.load() };
288
+ }
289
+ catch (error) {
290
+ deps.log?.(`hiwork-core: MCP 清单 RPC '${endpoint}' 失败(${error instanceof Error ? error.name : typeof error})`);
291
+ return { ok: false, error: failureOf(error) };
292
+ }
293
+ }
294
+ /** `assertion`:每次都要新的(120 秒的门票,缓存下来的那一张只会是废纸)。 */
295
+ async function handleAssertion(assignmentId, deps, signal) {
296
+ const sessionCookie = await deps.resolveSession();
297
+ if (sessionCookie === undefined || sessionCookie === '') {
298
+ return failure('SESSION_EXPIRED', MANAGED_MCP_SESSION_EXPIRED_MESSAGE);
299
+ }
300
+ const gateway = deps.gateway();
301
+ if (gateway === undefined) {
302
+ return failure('GATEWAY_UNREACHABLE', '网关地址未配置,请先在「网关模型」里完成登录');
303
+ }
304
+ if (isAborted(signal))
305
+ return failure('cancelled', 'MCP 清单请求已取消。');
306
+ const ticket = await gateway.issueMyMcpAssertion(sessionCookie, assignmentId);
307
+ const assertion = textOrUndefined(ticket?.assertion);
308
+ const mintUrl = textOrUndefined(ticket?.mintUrl);
309
+ if (assertion === undefined || mintUrl === undefined) {
310
+ deps.log?.('hiwork-core: 取断言收到无效响应(缺 assertion / mintUrl)');
311
+ return failure('internal', MANAGED_MCP_GENERIC_FAILURE_MESSAGE);
312
+ }
313
+ return {
314
+ ok: true,
315
+ value: {
316
+ assertion,
317
+ mintUrl,
318
+ revokeUrl: textOrUndefined(ticket?.revokeUrl) ?? '',
319
+ expiresAt: textOrUndefined(ticket?.expiresAt) ?? '',
320
+ serverName: textOrUndefined(ticket?.serverName) ?? '',
321
+ },
322
+ };
323
+ }
324
+ function failure(code, message) {
325
+ return { ok: false, error: { code, message, details: {} } };
326
+ }
327
+ /**
328
+ * 注册 `/hiwork-managed-mcp` 频道,返回异步 disposer(交给 `ctx.effect` 管理生命周期)。
329
+ */
330
+ export function registerManagedMcpRpc(ctx, deps, store) {
331
+ const handler = (endpoint, payload, signal) => handleManagedMcpEndpoint(endpoint, payload, deps, store, signal);
332
+ const remove = ctx.connection.rpc.handle(MANAGED_MCP_RPC_CHANNEL, handler, { authority: 'loopback' });
333
+ return async () => { await remove(); };
334
+ }