hiwork-core 0.0.8 → 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.
- package/lib/ai-router-client.js +55 -0
- package/lib/client-policy.js +13 -1
- package/lib/client.js +314 -23
- package/lib/index.js +310 -4
- package/lib/managed-mcp.js +334 -0
- package/lib/types/ai-router-client.d.ts +70 -0
- package/lib/types/client/contracts.d.ts +25 -0
- package/lib/types/client/controller.d.ts +13 -0
- package/lib/types/client/daily-card.d.ts +32 -1
- package/lib/types/client/managed-mcp.d.ts +133 -0
- package/lib/types/client/session-cost.d.ts +19 -13
- package/lib/types/client/styles.d.ts +10 -2
- package/lib/types/client/types.d.ts +7 -1
- package/lib/types/client-policy.d.ts +8 -0
- package/lib/types/index.d.ts +12 -3
- package/lib/types/managed-mcp.d.ts +177 -0
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -49,6 +49,11 @@ export interface AiRouterCardSettings {
|
|
|
49
49
|
keys?: GatewayKeyView[];
|
|
50
50
|
/** 网关策略下发的登录方式;缺省或非法值一律按 `both`。 */
|
|
51
51
|
loginMode?: LoginMode;
|
|
52
|
+
/**
|
|
53
|
+
* 网关策略下发:账号密码直连表单是否仍是受支持的登录方式。统一认证生效后网关报
|
|
54
|
+
* false(密码已由 IAM 持有,直连表单必然失败);缺省/非法一律按 true 展示入口。
|
|
55
|
+
*/
|
|
56
|
+
passwordLogin?: boolean;
|
|
52
57
|
/** 客户端写:请求 Host 发起浏览器授权登录(Host 完成后清掉本字段)。 */
|
|
53
58
|
browserLoginRequest?: {
|
|
54
59
|
at: string;
|
|
@@ -91,6 +96,8 @@ export interface AiRouterCardState {
|
|
|
91
96
|
activeKeyId: string | undefined;
|
|
92
97
|
/** 登录方式(投影后永不为 undefined;缺省/非法值归一为 both)。 */
|
|
93
98
|
loginMode: LoginMode;
|
|
99
|
+
/** 账号密码表单是否仍是受支持的入口(缺省 true:绝不收起可用入口)。 */
|
|
100
|
+
passwordLogin: boolean;
|
|
94
101
|
/** 授权页 URL:有值即等待浏览器授权中。 */
|
|
95
102
|
browserLoginUrl: string | undefined;
|
|
96
103
|
/** 是否等待浏览器授权(含请求已发出、URL 未回写的窗口期)。 */
|
|
@@ -135,6 +142,12 @@ export declare function authorizeUrlOf(value: AiRouterCardSettings | undefined):
|
|
|
135
142
|
* 后一种窗口期也算等待,避免用户重复点击发起第二次授权。
|
|
136
143
|
*/
|
|
137
144
|
export declare function browserLoginPendingOf(value: AiRouterCardSettings | undefined): boolean;
|
|
145
|
+
/**
|
|
146
|
+
* 账号密码直连表单是否仍是受支持的登录方式(网关策略下发)。
|
|
147
|
+
* 只有显式 false 才收起入口——缺省 / 非法值一律按 true(fail-open:旧网关不返回该
|
|
148
|
+
* 字段时不能把用户挡在密码入口之外)。
|
|
149
|
+
*/
|
|
150
|
+
export declare function passwordLoginOf(value: AiRouterCardSettings | undefined): boolean;
|
|
138
151
|
/**
|
|
139
152
|
* 浏览器登录的投影视图(登录门禁与卡片共用):`form` 模式下一律不暴露浏览器登录
|
|
140
153
|
* 入口——连残留的授权 URL 也一并抹掉,避免渲染层对旧 URL 再 `window.open` 一次。
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
* 任何失败都不出错误 UI、不打断主流程。问候语轮换(decorateHeroGreeting)不受影响、
|
|
11
11
|
* 照常每帧执行。
|
|
12
12
|
*
|
|
13
|
+
* 主行恒为单行:CSS 侧 nowrap 打底,字号按卡片可用宽度二分收敛(见
|
|
14
|
+
* solveDailyMainFontSize)——条目越长字越小,绝不折行、也不撑破卡片;下限按「池内
|
|
15
|
+
* 最长条目(24 字)在常规窗口宽度下不被截断」取值,低到下限仍放不下的极端条目才截断
|
|
16
|
+
* 显示,不会把两端一起裁掉。
|
|
17
|
+
*
|
|
13
18
|
* dcu 标记与 hiwork-dsh-ui 保持同名:两插件同环境共存时互认对方已挂的卡片,
|
|
14
19
|
* decorateDailyCard 的幂等检查只会让先挂者生效,不会双挂载。
|
|
15
20
|
*/
|
|
@@ -20,8 +25,34 @@ export interface DailyQuoteSource {
|
|
|
20
25
|
getRandom(excludeId?: number): Promise<DailyQuoteView | null>;
|
|
21
26
|
}
|
|
22
27
|
export declare const DAILY_CARD_STYLE_ID = "dcu-daily-card-style";
|
|
23
|
-
|
|
28
|
+
/**
|
|
29
|
+
* 主行字号:基准(一行放得下时)与下限(再长也不缩到不可读,剩余溢出走截断)。
|
|
30
|
+
* 下限由内容池约束:最长条目 24 字在 690px 卡片宽度下需要 ≤28px,取 24px 留出余量
|
|
31
|
+
* (更窄的窗口或更长的条目才走省略号)。
|
|
32
|
+
*/
|
|
33
|
+
export declare const DAILY_MAIN_FONT_SIZE_MAX = 64;
|
|
34
|
+
export declare const DAILY_MAIN_FONT_SIZE_MIN = 24;
|
|
35
|
+
export declare const DAILY_CARD_STYLE = "\n[data-dcu-daily-card]{box-sizing:border-box;display:flex;flex-direction:column;align-items:center;width:100%;max-width:min(880px,100%);margin:6px auto 44px;cursor:pointer;user-select:none;text-align:center}\n[data-dcu-daily-prefix]{display:inline-flex;align-items:center;gap:14px;font-size:20px;line-height:1.4;font-weight:500;letter-spacing:.22em;text-indent:.22em;color:rgba(24,24,27,.35);white-space:nowrap}\n[data-dcu-daily-prefix]::before,[data-dcu-daily-prefix]::after{content:\"\";display:inline-block;width:44px;height:2px;border-radius:1px;background:currentColor;opacity:.5}\n[data-dcu-daily-prefix][hidden]{display:none}\n[data-dcu-daily-main]{margin-top:6px;max-width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:64px;line-height:1.25;font-weight:650;letter-spacing:.005em;background:linear-gradient(100deg,#2f6bf0,#1d4ed8 45%,#0e7fc2);-webkit-background-clip:text;background-clip:text;color:transparent;transition:filter .18s ease}\n[data-dcu-daily-main][data-dcu-daily-truncated]{text-align:left}\n[data-dcu-daily-card]:hover [data-dcu-daily-main]{filter:brightness(1.12)}\n[data-dcu-daily-card]:focus-visible{outline:2px solid var(--dsw-alias-button-info-fill,#4c8dff);outline-offset:4px;border-radius:6px}\n[data-dcu-daily-shuffle]{flex:none;display:inline-flex;align-items:center;justify-content:center;gap:5px;margin-top:10px;padding:3px 10px;border-radius:8px;color:var(--dsw-alias-label-tertiary,#71717a);font-size:13px;line-height:20px;letter-spacing:.08em;text-indent:.08em;cursor:pointer;transition:color .18s ease}\n[data-dcu-daily-shuffle] svg{flex:none;width:13px;height:13px;transition:transform .45s ease}\n[data-dcu-daily-shuffle]:hover svg{transform:rotate(360deg)}\n[data-dcu-daily-shuffle]:hover{color:var(--dsw-alias-label-secondary,#3f3f46)}\n[data-dcu-daily-shuffle]:focus-visible{outline:2px solid var(--dsw-alias-button-info-fill,#4c8dff);outline-offset:2px}\nbody[data-ds-dark-theme] [data-dcu-daily-prefix]{color:rgba(242,244,248,.38)}\nbody[data-ds-dark-theme] [data-dcu-daily-main]{background:linear-gradient(100deg,#6b9bff,#3d7bff 45%,#4cc3ff);-webkit-background-clip:text;background-clip:text;color:transparent}\nbody[data-ds-dark-theme] [data-dcu-daily-shuffle]{color:var(--dsw-alias-label-tertiary,#9ba3b4)}\nbody[data-ds-dark-theme] [data-dcu-daily-shuffle]:hover{color:var(--dsw-alias-label-secondary,#a6adb8)}\n[data-dcu-daily-modal]{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(15,17,23,.55)}\n[data-dcu-daily-modal-box]{box-sizing:border-box;width:min(620px,100%);max-height:88vh;overflow:auto;border-radius:18px;border:1px solid var(--dsw-alias-border-subtle,rgba(24,24,27,.12));background:var(--dsw-alias-background-primary,#ffffff);box-shadow:0 24px 64px rgba(0,0,0,.28);padding:24px 28px;color:var(--dsw-alias-label-primary,#18181b)}\n[data-dcu-daily-modal-meta]{display:flex;align-items:center;justify-content:space-between;gap:12px;color:var(--dsw-alias-label-tertiary,#71717a);font-size:12.5px;line-height:18px;letter-spacing:.02em}\n[data-dcu-daily-modal-title]{margin-top:14px;font-size:24px;line-height:1.45;font-weight:650;color:var(--dsw-alias-label-primary,#18181b)}\n[data-dcu-daily-modal-body]{margin-top:16px;color:var(--dsw-alias-label-secondary,#3f3f46);font-size:14.5px;line-height:1.95}\n[data-dcu-daily-modal-body] > p{margin:0 0 10px}\n[data-dcu-daily-modal-body] > p:last-child{margin-bottom:0}\n[data-dcu-daily-modal-close]{appearance:none;margin-top:20px;padding:7px 18px;border:1px solid var(--dsw-alias-border-strong,rgba(24,24,27,.2));border-radius:9px;background:var(--dsw-alias-button-secondary-fill,transparent);color:var(--dsw-alias-label-secondary,#3f3f46);font-size:13px;line-height:20px;cursor:pointer}\n[data-dcu-daily-modal-close]:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(24,24,27,.06))}\n[data-dcu-daily-modal-close]:focus-visible{outline:2px solid var(--dsw-alias-button-info-fill,#4c8dff);outline-offset:2px}\nbody[data-ds-dark-theme] [data-dcu-daily-modal]{background:rgba(0,0,0,.62)}\nbody[data-ds-dark-theme] [data-dcu-daily-modal-box]{border-color:var(--dsw-alias-border-subtle,rgba(255,255,255,.12));background:var(--dsw-alias-background-primary,#10131b);color:var(--dsw-alias-label-primary,#f2f4f8)}\nbody[data-ds-dark-theme] [data-dcu-daily-modal-meta]{color:var(--dsw-alias-label-tertiary,#9ba3b4)}\nbody[data-ds-dark-theme] [data-dcu-daily-modal-title]{color:var(--dsw-alias-label-primary,#f2f4f8)}\nbody[data-ds-dark-theme] [data-dcu-daily-modal-body]{color:var(--dsw-alias-label-secondary,#a6adb8)}\nbody[data-ds-dark-theme] [data-dcu-daily-modal-close]{border-color:var(--dsw-alias-border-subtle,rgba(255,255,255,.16));color:var(--dsw-alias-label-secondary,#a6adb8)}\nbody[data-ds-dark-theme] [data-dcu-daily-modal-close]:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08))}\n@media (prefers-reduced-motion: reduce){[data-dcu-daily-card] [data-dcu-daily-main],[data-dcu-daily-shuffle],[data-dcu-daily-shuffle] svg,[data-dcu-daily-modal-box]{transition:none}}\n";
|
|
24
36
|
export declare function ensureDailyCardStyle(doc: Document): void;
|
|
37
|
+
/**
|
|
38
|
+
* 主行字号求解(纯函数,measure 注入以便测试):基准字号一行放得下 → null(调用方清掉
|
|
39
|
+
* 内联样式,回到 CSS 基准);否则二分出「放得下」的最大整数字号;低到下限仍放不下时按
|
|
40
|
+
* 下限返回(剩余溢出交给 CSS 截断兜底)。availableWidth 非正(无布局信息:容器隐藏 /
|
|
41
|
+
* jsdom)→ null,不动任何样式。
|
|
42
|
+
*
|
|
43
|
+
* 二分成立的前提是「文本宽度随字号单调递增」:字号与 em 单位字距同比例缩放,故对中文、
|
|
44
|
+
* 西文、混排均成立。
|
|
45
|
+
*/
|
|
46
|
+
export declare function solveDailyMainFontSize(availableWidth: number, measure: (fontSize: number) => number): number | null;
|
|
47
|
+
/**
|
|
48
|
+
* 把一张卡片的主行字号收敛到「一行放得下」:可用宽度取卡片内容盒宽度(卡片宽度由 CSS
|
|
49
|
+
* 的 width/max-width 决定,主行溢出不会撑大它),文本宽度取主行 scrollWidth(被截断时
|
|
50
|
+
* 仍是完整文本宽度,故二分不受裁剪影响)。返回最终写入的字号;null = 未写内联样式。
|
|
51
|
+
*
|
|
52
|
+
* 下限仍放不下的极端条目打上 `data-dcu-daily-truncated`:居中溢出会把两端一起裁掉、
|
|
53
|
+
* 看不出被截断,该状态改为左对齐以便省略号落在可见边上。
|
|
54
|
+
*/
|
|
55
|
+
export declare function fitDailyMainText(card: HTMLElement): number | null;
|
|
25
56
|
/**
|
|
26
57
|
* 探测 hero 页面并把主行插到 headline(后缀稳定,前缀哈希忽略)的「前一个兄弟」位置:
|
|
27
58
|
* 返回 headline 元素本身作为插入锚点(调用方以 headline.parentElement.insertBefore(card, headline) 落位)。
|