hiwork-core 0.0.10 → 0.0.11

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
+ }
@@ -209,6 +209,56 @@ export interface GatewayDailyQuoteResponse {
209
209
  export interface GatewayDailyQuoteRandomResponse {
210
210
  item: GatewayDailyQuoteItem | null;
211
211
  }
212
+ /**
213
+ * 一条下发给当前账号的 MCP 分配(`GET /api/me/mcp-assignments` 的 `assignments[]`)。
214
+ *
215
+ * **字段名是跨仓冻结契约**(ai-hub spec §4.1):四仓(visio-mcp / ai-hub / hiwork-core /
216
+ * hiwork-capabilities)唯一的接口面,改一个字段名要先改 spec 并同步四仓注释。
217
+ */
218
+ export interface GatewayMcpAssignment {
219
+ /** 服务端分配 id:客户端按它派生连接 key(`admin-<assignmentId>`),因此必须稳定。 */
220
+ assignmentId: string;
221
+ /** 单条版本号:管理员每次推送 +1,客户端据此判「这条要不要重新落地」。 */
222
+ revision: number;
223
+ /** 服务的 slug(ai-hub 侧唯一名,也是断言的 audience)。 */
224
+ slug: string;
225
+ displayName: string;
226
+ /** 服务端权威下发的装载名(工具前缀 `mcp__<serverName>__` 跨版本稳定的锚点)。 */
227
+ serverName: string;
228
+ /** `streamable-http | stdio`(与 capabilities 的传输判别联合同源)。 */
229
+ transport: string;
230
+ url: string;
231
+ /** 非凭证的业务请求头;`{}` 表示没有。 */
232
+ extraHeaders: Record<string, string>;
233
+ auth: GatewayMcpAssignmentAuth;
234
+ updatedAt: string;
235
+ }
236
+ /** 取凭证的入口:去哪换(mintUrl)、去哪还(revokeUrl)。`kind` 目前只有 `assertion`。 */
237
+ export interface GatewayMcpAssignmentAuth {
238
+ kind: string;
239
+ mintUrl: string;
240
+ revokeUrl: string;
241
+ }
242
+ /**
243
+ * GET /api/me/mcp-assignments 响应。
244
+ * `revision` 是清单级序号(任何会影响清单的写入都 +1),客户端先比它、不同再逐条深比。
245
+ */
246
+ export interface GatewayMcpAssignmentsView {
247
+ revision: number;
248
+ assignments: GatewayMcpAssignment[];
249
+ }
250
+ /**
251
+ * POST /api/me/mcp-assertions 响应:一张 **120 秒**的身份断言(RS256 JWT)+
252
+ * 去哪换令牌。断言本身不是凭据(换不成令牌就是废纸),所以它可以出现在这里;
253
+ * 真正的 per-user 令牌由 MCP 服务在 exchange 时一次性返回,网关与核心都拿不到。
254
+ */
255
+ export interface GatewayMcpAssertionTicket {
256
+ assertion: string;
257
+ mintUrl: string;
258
+ revokeUrl: string;
259
+ expiresAt: string;
260
+ serverName: string;
261
+ }
212
262
  /** 网关客户端端口:orchestration 只依赖此接口。 */
213
263
  export interface GatewayClient {
214
264
  /**
@@ -248,6 +298,10 @@ export interface GatewayClient {
248
298
  getMyKeyRecharges(sessionCookie: string, keyId?: string, modelName?: string): Promise<GatewayRecharge[]>;
249
299
  /** 网关权威计价:只回金额与生效单价,客户端不做任何算式。 */
250
300
  estimateCost(sessionCookie: string, items: GatewayCostEstimateItem[]): Promise<GatewayCostEstimateResponse>;
301
+ /** 管理员下发给当前账号的 MCP 清单(响应里没有令牌)。 */
302
+ getMyMcpAssignments(sessionCookie: string): Promise<GatewayMcpAssignmentsView>;
303
+ /** 为某条分配签一张 120 秒的身份断言(拿它去 MCP 服务换 per-user 令牌)。 */
304
+ issueMyMcpAssertion(sessionCookie: string, assignmentId: string): Promise<GatewayMcpAssertionTicket>;
251
305
  logout(sessionCookie: string): Promise<void>;
252
306
  }
253
307
  /**
@@ -313,6 +367,22 @@ export declare class AiRouterClient implements GatewayClient {
313
367
  * `input_tokens` 不含 cache;任何折算与单价计算都在网关侧完成。
314
368
  */
315
369
  estimateCost(sessionCookie: string, items: GatewayCostEstimateItem[]): Promise<GatewayCostEstimateResponse>;
370
+ /**
371
+ * GET /api/me/mcp-assignments:管理员下发给当前账号的 MCP 清单。
372
+ *
373
+ * 不复用 {@link getMeJson}:那条路径把 403 映射成 `NO_KEY_ASSIGNED`(「尚未被分配
374
+ * 虚拟 KEY」),而本端点的鉴权只有会话——一个还没被分配 Key 的账号照样应该有
375
+ * 清单(分 MCP 与分 Key 是两件事)。沿用它的 403 文案会把「没有 Key」错报成原因。
376
+ */
377
+ getMyMcpAssignments(sessionCookie: string): Promise<GatewayMcpAssignmentsView>;
378
+ /**
379
+ * POST /api/me/mcp-assertions:为某条分配签一张 120 秒断言。
380
+ *
381
+ * 三种拒绝各有确定含义(服务端 handler 的 404/409/503),必须分开映射——否则
382
+ * 客户端只会看到一句「取断言失败」,既分不清「被撤销了」(该卸连接)还是
383
+ * 「服务停用了」(该重试)还是「部署没配私钥」(找管理员)。
384
+ */
385
+ issueMyMcpAssertion(sessionCookie: string, assignmentId: string): Promise<GatewayMcpAssertionTicket>;
316
386
  /**
317
387
  * /api/me 自助端点的 GET 公共路径:网络异常 → GATEWAY_UNREACHABLE,
318
388
  * 401/403/400 映射为稳定 code,响应体非 JSON 时不把原文带进异常。
@@ -21,6 +21,7 @@
21
21
  * 即可(见 `hiwork-automation` 的 `contracts.ts`),运行期只按服务名读取。
22
22
  */
23
23
  import type { FeatureRegistry } from './feature-registry.ts';
24
+ import type { ManagedMcpAssertion, ManagedMcpSnapshot } from './managed-mcp.ts';
24
25
  /** cordis 服务名:`hiwork-core` 的 client 半边提供,业务插件按名注入。 */
25
26
  export declare const HIWORK_FEATURE_CENTER_SERVICE = "hiworkFeatureCenter";
26
27
  /**
@@ -38,4 +39,28 @@ export interface HiWorkCoreClientFace {
38
39
  readonly openNativeSession: (sessionId: string) => void;
39
40
  /** 回到原生「新会话」首页:先退出 takeover,再触发原生新会话动作。 */
40
41
  readonly openNativeHome: () => void;
42
+ /**
43
+ * 管理员下发的 MCP(连接器域消费,见 `src/client/managed-mcp.ts`)。
44
+ *
45
+ * **可选成员**:老版本 hiwork-core 没有这个面、新版本 hiwork-capabilities 遇到它
46
+ * 时只会看到 `undefined`(能力中心照常工作,只是没有受管连接);反过来新 core 遇到
47
+ * 老 capabilities 也只是没人来取。两侧因此不必严格同发一个版本。
48
+ *
49
+ * 这一面**只交出清单与短期断言**:换 per-user 令牌是连接器域自己拿断言去 MCP 服务
50
+ * 做的,核心既不换也不存。
51
+ */
52
+ readonly managedMcp?: {
53
+ /** 本地镜像(同步读,可能是上一次成功的结果)。**不要拿它做删除决策**。 */
54
+ readonly snapshot: () => ManagedMcpSnapshot | undefined;
55
+ /** 本地镜像是否陈旧(或从未同步过)。 */
56
+ readonly isStale: (ttlMs?: number) => boolean;
57
+ /** 用 Host 的预热缓存拿一份清单(命中缓存时零网络请求)。 */
58
+ readonly warm: (signal?: AbortSignal) => Promise<ManagedMcpSnapshot>;
59
+ /** 强制拉一次,返回**刚拉到的**清单;失败抛错。只有它的返回值保证是当下的真相。 */
60
+ readonly refresh: (signal?: AbortSignal) => Promise<ManagedMcpSnapshot>;
61
+ /** 取一张 120 秒的身份断言 + 去哪换令牌。 */
62
+ readonly assertion: (assignmentId: string, signal?: AbortSignal) => Promise<ManagedMcpAssertion>;
63
+ /** 订阅镜像变化(登出 / 换账号时会被清空)。返回退订函数。 */
64
+ readonly subscribe: (listener: () => void) => () => void;
65
+ };
41
66
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * 「管理员下发的 MCP」客户端取数层:经 `/hiwork-managed-mcp` loopback RPC 拿到
3
+ * 清单与短期断言,交给连接器域(hiwork-capabilities)去换令牌并落地。
4
+ *
5
+ * 铁律:
6
+ * - 客户端**不持有**会话 cookie,也**不直接 fetch 网关**——一切经 Host 频道;
7
+ * - 客户端**不做 exchange、不持有 MCP 令牌**:本层交出去的只有「清单 + 一张刚取的
8
+ * 断言 + mintUrl」,per-user 令牌只在连接器域内部存在;
9
+ * - **只有 {@link ManagedMcpStore.refresh} 成功返回的清单才允许用作删除本地受管连接的
10
+ * 依据**;{@link ManagedMcpStore.snapshot} 可能与界面展示共享,但它可能是上一次
11
+ * 成功的结果(服务端刚撤掉的分配还在里面),拿它做删除决策就会把「撤销」误做成
12
+ * 「没同步上」——反过来,拉取失败时它又是唯一的真相来源,所以两个入口必须分开。
13
+ *
14
+ * client bundle 纯度:本文件不 import 任何 `@deepseek-ai/*` 运行时值,也不 import
15
+ * Host 代码(类型按结构重声明,字段名与契约一致)。
16
+ */
17
+ /** RPC 频道;必须与 Host `src/managed-mcp.ts` 的 `MANAGED_MCP_RPC_CHANNEL` 一致(由测试锁定)。 */
18
+ export declare const MANAGED_MCP_CHANNEL = "/hiwork-managed-mcp";
19
+ /** 端点白名单;必须与 Host `MANAGED_MCP_RPC_ENDPOINTS` 一致(由测试锁定)。 */
20
+ export declare const MANAGED_MCP_ENDPOINTS: readonly ["snapshot", "refresh", "assertion"];
21
+ export type ManagedMcpEndpoint = (typeof MANAGED_MCP_ENDPOINTS)[number];
22
+ /**
23
+ * 本地镜像的「陈旧」阈值:5 分钟。
24
+ * 触发时机里的「打开连接器页且 >5min stale 就同步一次」用它判定——短于它只会让
25
+ * 用户每次切页都打一遍网关,长于它则界面上的清单明显落后于管理员的推送。
26
+ */
27
+ export declare const MANAGED_MCP_STALE_MS: number;
28
+ /** 一条分配(对齐 ai-hub spec §4.1 的冻结契约)。 */
29
+ export interface ManagedMcpAssignment {
30
+ assignmentId: string;
31
+ revision: number;
32
+ slug: string;
33
+ displayName: string;
34
+ /** 服务端权威下发的装载名:工具前缀 `mcp__<serverName>__` 跨版本稳定的锚点。 */
35
+ serverName: string;
36
+ /** `streamable-http | stdio`;认不认识的判断在连接器域(不认识的整条跳过并报冲突)。 */
37
+ transport: string;
38
+ url: string;
39
+ extraHeaders: Record<string, string>;
40
+ auth: {
41
+ kind: string;
42
+ mintUrl: string;
43
+ revokeUrl: string;
44
+ };
45
+ updatedAt: string;
46
+ }
47
+ /** 一份清单快照。`stale` 为 true 表示最近一次拉取失败,展示的是上一次成功的结果。 */
48
+ export interface ManagedMcpSnapshot {
49
+ revision: number;
50
+ fetchedAt: string;
51
+ assignments: ManagedMcpAssignment[];
52
+ stale: boolean;
53
+ lastError?: {
54
+ code: string;
55
+ message: string;
56
+ };
57
+ }
58
+ /** 取断言的结果:一张 120 秒的门票 + 去哪换令牌。 */
59
+ export interface ManagedMcpAssertion {
60
+ assertion: string;
61
+ mintUrl: string;
62
+ revokeUrl: string;
63
+ expiresAt: string;
64
+ serverName: string;
65
+ }
66
+ /** 宿主 loopback RPC 调用面(结构兼容 `ctx.connection.rpc`)。 */
67
+ export interface ManagedMcpRpcPort {
68
+ call(channel: string, endpoint: string, payload: unknown, signal?: AbortSignal): Promise<unknown>;
69
+ }
70
+ /** 带稳定 code 的取数错误(message 已由 Host 消毒,可直接展示)。 */
71
+ export declare class ManagedMcpRpcError extends Error {
72
+ readonly code: string;
73
+ constructor(code: string, message: string);
74
+ }
75
+ /**
76
+ * 解开 DSH Connection 的结果信封。
77
+ * 宿主返回 `{ok:true,value}` / `{ok:false,error:{code,message}}`;形状不合法时抛
78
+ * 通用失败,绝不把未知结构当数据用。
79
+ */
80
+ export declare function unwrapManagedMcpResult<T>(value: unknown): T;
81
+ /** 任意异常 → 可展示的 `{code, message}`(传输层异常不暴露原始 message)。 */
82
+ export declare function managedMcpErrorOf(error: unknown): {
83
+ code: string;
84
+ message: string;
85
+ };
86
+ /**
87
+ * 受管 MCP 的取数仓库。
88
+ *
89
+ * 本地镜像(`snapshot()` 同步读)是有必要的:DSH 的 client 半边在不同插件之间只通过
90
+ * 这个面同步取状态,而清单本身是异步来的。镜像**只由成功响应覆写**,失败时不改动它。
91
+ */
92
+ export declare class ManagedMcpStore {
93
+ private readonly rpc;
94
+ private current;
95
+ private readonly listeners;
96
+ private inFlight;
97
+ constructor(rpc: ManagedMcpRpcPort);
98
+ /** 本地镜像(同步)。从没成功同步过返回 undefined。**不要拿它做删除决策**(见文件头)。 */
99
+ snapshot(): ManagedMcpSnapshot | undefined;
100
+ /** 本地镜像是否已经陈旧(或从未同步过)。 */
101
+ isStale(ttlMs?: number, now?: number): boolean;
102
+ /**
103
+ * 强制同步一次并返回**刚拉到的**清单。
104
+ *
105
+ * 返回值(而不是让人随后去读 `snapshot()`)是刻意的:只有这个返回值保证「这一次
106
+ * 网络请求成功了」,用它做删除决策才是安全的;读镜像可能读到上一次的结果。
107
+ * 失败时抛错且**不动镜像**——调用方据此保持现有的连接一条不删。
108
+ */
109
+ refresh(signal?: AbortSignal): Promise<ManagedMcpSnapshot>;
110
+ /**
111
+ * 取一份清单,**能用 Host 的预热缓存就用**(Host 后台每 30 分钟拉一次)。
112
+ *
113
+ * 与 {@link refresh} 的分工:这里是「启动时先把界面填上」的便宜入口——命中缓存时
114
+ * 一次网络请求都不发;Host 缓存也是冷的才会真的去拉。返回值仍保证是「某一次成功
115
+ * 拉取的结果」,但**可能比服务端现状旧**(最旧不超过 Host 的上一次成功拉取),
116
+ * 所以调用方按 `stale` / `isStale()` 判定它够不够权威,不够就再 `refresh()` 一次。
117
+ */
118
+ warm(signal?: AbortSignal): Promise<ManagedMcpSnapshot>;
119
+ /** 取一张新鲜断言(120 秒)。失败抛错;本层不缓存、不换令牌。 */
120
+ assertion(assignmentId: string, signal?: AbortSignal): Promise<ManagedMcpAssertion>;
121
+ /** 订阅镜像变化(连接器域据此重渲染 / 重排同步)。返回退订函数。 */
122
+ subscribe(listener: () => void): () => void;
123
+ /** 清空镜像(登出 / 换账号:清单是按人的,留着会串号)。 */
124
+ clear(): void;
125
+ /**
126
+ * 并发去重:`warm` 与 `refresh` 撞在一起(启动同步与定时同步常常同拍)时共用一次
127
+ * 请求,且**共用同一个返回值**——两边对「这一次拉到了什么」的判断必须一致。
128
+ */
129
+ private dedupe;
130
+ /** 采纳一份成功响应:写镜像 + 通知订阅者(失败路径永远不会走到这里)。 */
131
+ private adopt;
132
+ private emit;
133
+ }
@@ -1,5 +1,11 @@
1
1
  /** 稳定错误码,供 settings card 分支渲染。 */
2
- export type LoginErrorCode = 'AUTH_FAILED' | 'RATE_LIMITED' | 'GATEWAY_UNREACHABLE' | 'NO_KEY_ASSIGNED' | 'SESSION_EXPIRED' | 'KEY_DISABLED' | 'KEY_INVALID' | 'NOT_FOUND';
2
+ export type LoginErrorCode = 'AUTH_FAILED' | 'RATE_LIMITED' | 'GATEWAY_UNREACHABLE' | 'NO_KEY_ASSIGNED' | 'SESSION_EXPIRED' | 'KEY_DISABLED' | 'KEY_INVALID' | 'NOT_FOUND'
3
+ /** 该分配已被管理员撤销(或从未属于当前账号):取断言时 404。 */
4
+ | 'MCP_ASSIGNMENT_GONE'
5
+ /** 服务或分配已停用(或被改成了不可下发的凭证方式):取断言时 409。 */
6
+ | 'MCP_NOT_DELIVERABLE'
7
+ /** 本部署未配置断言签名私钥(`mcp.assertion_key`):取断言时 503。 */
8
+ | 'MCP_ASSERTION_DISABLED';
3
9
  /** 一个稳定的、可安全展示的登录/刷新错误。 */
4
10
  export interface LoginError {
5
11
  code: LoginErrorCode;