hiwork-core 0.0.11 → 0.0.17

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,113 @@
1
+ /**
2
+ * 文档中继的 host 侧服务 `hiworkOfficeRelay`(设计见 ai-gateway 的
3
+ * `docs/superpowers/specs/2026-09-16-office-document-relay-design.md` §5)。
4
+ *
5
+ * 为什么需要它:在线编辑要求 Document Server 主动回连文档 URL;用户走 VPN / DS 在云上时
6
+ * 这条路径不通(2026-09-16 现场:DS 拉 http://192.168.16.105:54426 超时)。中继把文档
7
+ * 暂存到 DS 能访问的服务器侧,桌面端只做出站调用——**用户侧不再需要任何入向可达性**。
8
+ *
9
+ * 与 `office-credentials.ts` 同一套形态:
10
+ * - **网关会话 cookie 绝不离开 Host**:只在 resolveSession() 内部出现;
11
+ * - 失败不抛异常,返回稳定 `{ ok, code, message }`(message 已消毒,可直接展示);
12
+ * - 服务名 `hiworkOfficeRelay`,经 ctx.provide 发布;hiwork-office 用 ctx.get 探测,
13
+ * 老 core 上没有它 → 插件回落本机模式。
14
+ *
15
+ * 本模块不 import cordis 运行时:provide 走结构化的 {@link OfficeRelayContext} 端口。
16
+ */
17
+ import { GatewayError } from "./ai-router-client.js";
18
+ /** cordis 服务名(hiwork-office 的 Host 半边按此名探测)。 */
19
+ export const OFFICE_RELAY_SERVICE = 'hiworkOfficeRelay';
20
+ /** 各稳定错误码的用户可见文案(网关 message 含实现细节时用它兜底)。 */
21
+ const OFFICE_RELAY_MESSAGE_FALLBACK = {
22
+ SESSION_EXPIRED: '登录状态已过期,请重新登录',
23
+ GATEWAY_UNREACHABLE: '网关暂时不可用,请稍后重试',
24
+ RELAY_DISABLED: '本部署未启用文档中继,请联系管理员',
25
+ RELAY_EXPIRED: '编辑会话已过期,请重新打开文档',
26
+ RELAY_TOO_LARGE: '文档超过在线编辑的大小上限',
27
+ RELAY_EXTENSION: '该文档类型不支持在线编辑',
28
+ RELAY_QUOTA: '同时打开的编辑会话过多,请稍后再试',
29
+ };
30
+ /** 通用失败文案:不暴露任何网关细节。 */
31
+ export const OFFICE_RELAY_GENERIC_FAILURE_MESSAGE = '文档中继暂时不可用,请稍后重试';
32
+ /** 未登录 / 会话失效的统一文案(与其余频道同口径)。 */
33
+ export const OFFICE_RELAY_SESSION_EXPIRED_MESSAGE = '登录状态已过期,请重新登录';
34
+ /**
35
+ * 网关 message 消毒(与 `office-credentials.ts` 同实现,各频道自带一份)。
36
+ */
37
+ export function displayOfficeRelayMessage(code, message) {
38
+ const text = typeof message === 'string' ? message.trim() : '';
39
+ if (text === '')
40
+ return OFFICE_RELAY_MESSAGE_FALLBACK[code] ?? OFFICE_RELAY_GENERIC_FAILURE_MESSAGE;
41
+ const leaksImplementationDetail = /https?:\/\//i.test(text)
42
+ || /\bHTTP\s*\d{3}\b/i.test(text)
43
+ || /\bbaseURL\b/i.test(text);
44
+ if (leaksImplementationDetail)
45
+ return OFFICE_RELAY_MESSAGE_FALLBACK[code] ?? OFFICE_RELAY_GENERIC_FAILURE_MESSAGE;
46
+ return text;
47
+ }
48
+ /** 凭据取数(无缓存;缓存策略是消费方的事)。 */
49
+ export class OfficeRelayBroker {
50
+ deps;
51
+ constructor(deps) {
52
+ this.deps = deps;
53
+ }
54
+ async withSession(run) {
55
+ try {
56
+ const sessionCookie = await this.deps.resolveSession();
57
+ if (sessionCookie === undefined || sessionCookie === '') {
58
+ return { ok: false, code: 'SESSION_EXPIRED', message: OFFICE_RELAY_SESSION_EXPIRED_MESSAGE };
59
+ }
60
+ const gateway = this.deps.gateway();
61
+ if (gateway === undefined) {
62
+ return { ok: false, code: 'GATEWAY_UNREACHABLE', message: '网关地址未配置,请先在「网关模型」里完成登录' };
63
+ }
64
+ return { ok: true, value: await run(sessionCookie, gateway) };
65
+ }
66
+ catch (error) {
67
+ if (error instanceof GatewayError) {
68
+ return { ok: false, code: error.code, message: displayOfficeRelayMessage(error.code, error.message) };
69
+ }
70
+ this.deps.log?.(`hiwork-core: 文档中继调用失败(${error instanceof Error ? error.name : typeof error})`);
71
+ return { ok: false, code: 'internal', message: OFFICE_RELAY_GENERIC_FAILURE_MESSAGE };
72
+ }
73
+ }
74
+ async upload(input, signal) {
75
+ if (signal?.aborted === true)
76
+ return { ok: false, code: 'cancelled', message: '上传已取消。' };
77
+ return await this.withSession(async (cookie, gateway) => {
78
+ const created = await gateway.createOfficeRelayDocument(cookie, input.name, input.bytes, input.mode);
79
+ return {
80
+ id: created.id,
81
+ documentKey: created.documentKey,
82
+ readUrl: created.readUrl,
83
+ callbackUrl: created.callbackUrl,
84
+ expiresAt: created.expiresAt,
85
+ };
86
+ });
87
+ }
88
+ async status(id, _signal) {
89
+ return await this.withSession(async (cookie, gateway) => await gateway.getOfficeRelayStatus(cookie, id));
90
+ }
91
+ async download(id, revision, _signal) {
92
+ return await this.withSession(async (cookie, gateway) => {
93
+ const content = await gateway.getOfficeRelayContent(cookie, id, revision);
94
+ return content.notModified
95
+ ? { notModified: true }
96
+ : { notModified: false, ...(content.bytes === undefined ? {} : { bytes: content.bytes }) };
97
+ });
98
+ }
99
+ async close(id, _signal) {
100
+ return await this.withSession(async (cookie, gateway) => {
101
+ await gateway.closeOfficeRelayDocument(cookie, id);
102
+ return {};
103
+ });
104
+ }
105
+ }
106
+ /**
107
+ * 提供 host 侧服务 `hiworkOfficeRelay`;返回 disposer(交给 `ctx.effect`)。
108
+ */
109
+ export function provideOfficeRelay(ctx, deps) {
110
+ const service = new OfficeRelayBroker(deps);
111
+ const dispose = ctx.provide(OFFICE_RELAY_SERVICE, service);
112
+ return typeof dispose === 'function' ? dispose : () => { };
113
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `/hiwork-plugin-updates` loopback RPC 的 Host 实现:把**桌面端**的 `desktopPlugins`
3
+ * (受管插件清单的只读面 + 唯一的回收入口)搬到渲染端够得着的地方。
4
+ *
5
+ * 为什么必须有这条 RPC(spec §4.5 的「数据来源」一段):`desktopPlugins` 是桌面端的
6
+ * `desktop-bridge.mts` 用 `ctx.provide('desktopPlugins', …)` 提供到 **DSH host** ctx 上的,
7
+ * 而 hiwork-core 的客户端跑在 **renderer**——两个进程。客户端现有的全部数据层(usage /
8
+ * session-cost / daily / managed-mcp / managed-skills / managed-workbench / app-version)
9
+ * 无一例外都经 `ctx.get('connection').rpc` 走 `/hiwork-*` loopback RPC,**没有任何
10
+ * client→host 的服务直连通道**。而「重启并更新」是个**点击**,它发生在客户端。所以取数与
11
+ * 触发都得过这条 RPC。
12
+ *
13
+ * 骨架照 `src/workbench-bridge.ts`(同 `src/managed-skills.ts` / `src/app-version-rpc.ts`):
14
+ * 导出频道名 + 端点白名单(`as const`)+ 结构化 deps 端口(**不 import cordis 运行时**,
15
+ * 便于单测直接伪造)+ 一个 `register…Rpc(...)` 返回 disposer。
16
+ *
17
+ * 三条本频道特有的纪律:
18
+ * - ⚠️ **`desktopPlugins` 是可选的**(不变量:依赖必须是可选的)。老桌面端上没有它 —— 那正是
19
+ * 「新 core + 老桌面端」这条共存路径。拿不到时 `snapshot` 回**成功信封 + `null`**
20
+ * (「不可用」**不是错误**,与 `app-version-rpc.ts` 的「桌面端没推过不是错误」同一条口径),
21
+ * 客户端据此**不渲染任何东西**。抛错会把共存路径变成一条报错日志。
22
+ * - ⚠️ **只搬运 `packages` / `revision` / `fetchedAt` 这三样**,**不搬 `held`**(plan T11
23
+ * Step 0)。它们三个是真的:落在 `.dsh-pending-updates.json` 里,跨进程可用。`held` /
24
+ * `from` / `displayName` 在生产路径上是空的——根因是 `desktop-bridge.mts` 调
25
+ * `createDesktopHostServices()` 时**没传 `pluginManifest`**,于是 `desktop-host.ts` 里那条
26
+ * `memory = options.pluginManifest?.current()` 恒 `undefined`、`held` 退到 `?? []`。
27
+ * 那条通道要修得补一条**主进程 → 子进程**的路(新 IPC 信封或 sidecar 文件),而**本 RPC 是
28
+ * client→host 的**,它跨的是「renderer↔DSH」,**跨不过「DSH↔主进程」这一层**。所以别「顺手把
29
+ * `held` 也传过去」——传过去也永远是空的,只会让下一个读这段的人以为服务端没下发降级项。
30
+ * - ⚠️ **`apply` 不是「更新成功」的回执**:它只把触发递下去(`desktopPlugins.applyNow()` →
31
+ * `APPLY_PLUGIN_UPDATES_IPC` → 主进程的 `recycleDshForPluginUpdate()`),重启由**主进程**执行、
32
+ * 客户端那一刻就没了。按钮语义是「已请求重启」。所以返回值是 `{ requested }`,
33
+ * **不是** `{ applied: true }`。
34
+ *
35
+ * 回收红线(spec §6.1 不变量②):本文件的 `apply` 是**唯一**允许触发回收的新入口,且只能由
36
+ * 用户点击到达(客户端只在「重启并更新」按钮的 onClick 里调它)。`snapshot` **绝不触碰**
37
+ * `applyNow()`——清单拉取、轮询、启动流程都不许触发回收。
38
+ */
39
+ /** RPC 频道;必须与 client 半边 `src/client/plugin-updates.ts` 的 `PLUGIN_UPDATES_CHANNEL` 一致(由测试锁定)。 */
40
+ export const PLUGIN_UPDATES_RPC_CHANNEL = '/hiwork-plugin-updates';
41
+ /**
42
+ * 端点白名单:只有两个。
43
+ *
44
+ * `snapshot` 是「现在有没有待生效的更新」(桌面端读 pending 文件,**不拉清单、不写文件、
45
+ * 不触发回收**),`apply` 是「现在就去重启并应用」——它只在用户点击时被调用。
46
+ * 白名单是客户端可用的能力边界,多一项就等于先发布了一个没有实现的承诺。
47
+ */
48
+ export const PLUGIN_UPDATES_RPC_ENDPOINTS = ['snapshot', 'apply'];
49
+ /** 请求侧错误(只有 `bad-request`:未知端点 / 非法 payload)。 */
50
+ export class PluginUpdatesRequestError extends Error {
51
+ code;
52
+ constructor(code, message) {
53
+ super(message);
54
+ this.code = code;
55
+ this.name = 'PluginUpdatesRequestError';
56
+ }
57
+ }
58
+ /** 通用失败文案:不暴露任何桌面端细节。 */
59
+ export const PLUGIN_UPDATES_GENERIC_FAILURE_MESSAGE = '插件更新状态暂时无法获取,请稍后重试';
60
+ /** 是否为白名单端点。 */
61
+ export function isPluginUpdatesRpcEndpoint(endpoint) {
62
+ return PLUGIN_UPDATES_RPC_ENDPOINTS.includes(endpoint);
63
+ }
64
+ /** 宽松的记录判定(数组与 null 都不算)。 */
65
+ function isRecord(value) {
66
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
67
+ }
68
+ /** 非空字符串或 undefined。 */
69
+ function textOrUndefined(value) {
70
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
71
+ }
72
+ /** 取消判定(用函数包一层,避免 TS 把属性窄化固化到 `await` 之后)。 */
73
+ function isAborted(signal) {
74
+ return signal?.aborted === true;
75
+ }
76
+ /**
77
+ * payload 校验:两个端点都**只放行空对象**(本频道没有任何入参)。
78
+ *
79
+ * 与 workbench / app-version 的空入参分支逐字同款:出现任何字段都是客户端实现缺陷,直接
80
+ * `bad-request`——一个 `{ force: true }` 被静默忽略,会让调用方以为「强制重启」生效了。
81
+ */
82
+ export function parsePluginUpdatesPayload(endpoint, raw) {
83
+ if (raw === undefined || raw === null)
84
+ return {};
85
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
86
+ throw new PluginUpdatesRequestError('bad-request', '插件更新请求参数不合法。');
87
+ }
88
+ const keys = Object.keys(raw);
89
+ const first = keys[0];
90
+ if (first !== undefined) {
91
+ throw new PluginUpdatesRequestError('bad-request', `插件更新请求包含未知字段 '${first}'。`);
92
+ }
93
+ // endpoint 已在调用方过白名单;这里保留参数是为了类型上与其它频道的 parse* 同形。
94
+ void endpoint;
95
+ return {};
96
+ }
97
+ /**
98
+ * 一条更新的规范化:**只在缺 `packageName` 时丢条目**,其余兜底。
99
+ *
100
+ * 为什么判据是 `packageName` 而不是「`to` 是不是精确版本」:界面**只**依赖
101
+ * `packages.length > 0`(spec §4.3 的 ⚠️)。在这里按更严的形状丢条目,等于把一份真的待生效
102
+ * 更新静默吃掉——用户就再也等不到重启提示了。所以「形状兜底」在此处的方向是**保住计数**:
103
+ * 只要这条还指得出是哪个包就留下,`to` 缺了就记空串(界面本来也不渲染它)。
104
+ * 反过来,缺 `packageName` 的条目连「有没有东西要装」都回答不了,留着只会让界面误报。
105
+ */
106
+ function toPackageUpdate(raw) {
107
+ if (!isRecord(raw))
108
+ return undefined;
109
+ const packageName = textOrUndefined(raw['packageName']);
110
+ if (packageName === undefined)
111
+ return undefined;
112
+ return {
113
+ packageName,
114
+ displayName: textOrUndefined(raw['displayName']) ?? packageName,
115
+ from: textOrUndefined(raw['from']) ?? '',
116
+ to: textOrUndefined(raw['to']) ?? '',
117
+ };
118
+ }
119
+ /**
120
+ * 把 `desktopPlugins.pending()` 的返回值规范化成 {@link PluginUpdatesSnapshot}。
121
+ *
122
+ * `undefined` / 非对象 → `undefined`(= 没有待生效项,界面不渲染)。**只有降级项(`held` 非空、
123
+ * `packages` 为空)时同样落成空 `packages`**,由客户端据此不渲染——回退对员工是要解释的事,
124
+ * 不该混在「更新已就绪」里(spec §4.5)。`held` 本身不搬运,理由见文件头。
125
+ */
126
+ export function toSnapshot(raw) {
127
+ if (!isRecord(raw))
128
+ return undefined;
129
+ const packages = [];
130
+ for (const item of Array.isArray(raw['packages']) ? raw['packages'] : []) {
131
+ const normalized = toPackageUpdate(item);
132
+ if (normalized !== undefined)
133
+ packages.push(normalized);
134
+ }
135
+ const revision = raw['revision'];
136
+ const fetchedAt = raw['fetchedAt'];
137
+ return {
138
+ packages,
139
+ fetchedAt: typeof fetchedAt === 'number' && Number.isFinite(fetchedAt) ? fetchedAt : 0,
140
+ revision: typeof revision === 'number' && Number.isFinite(revision) ? revision : null,
141
+ };
142
+ }
143
+ /**
144
+ * 处理一次端点调用:返回 DSH Connection 的结果信封,**永不抛异常**。
145
+ * 导出以便单测直接驱动(无需伪造 `ctx.connection`)。
146
+ */
147
+ export async function handlePluginUpdatesEndpoint(endpoint, rawPayload, deps, signal) {
148
+ try {
149
+ if (isAborted(signal))
150
+ return failure('cancelled', '插件更新请求已取消。');
151
+ if (!isPluginUpdatesRpcEndpoint(endpoint)) {
152
+ throw new PluginUpdatesRequestError('bad-request', `未知的插件更新端点 '${endpoint}'`);
153
+ }
154
+ parsePluginUpdatesPayload(endpoint, rawPayload);
155
+ const plugins = deps.desktopPlugins();
156
+ if (endpoint === 'snapshot') {
157
+ // 「不可用」是**成功信封 + null**,不是错误(老桌面端共存的正常路径,见文件头)。
158
+ const value = plugins === undefined ? null : toSnapshot(await plugins.pending()) ?? null;
159
+ return { ok: true, value };
160
+ }
161
+ // apply:**唯一**的回收触发点,只在用户点击时到达这里。
162
+ if (plugins === undefined) {
163
+ // 不可用时不调 applyNow(没有可调的对象),同样按「不是错误」回执:
164
+ // `requested: false` 如实告诉客户端「这次请求没有被递下去」。
165
+ return { ok: true, value: { requested: false } };
166
+ }
167
+ await plugins.applyNow();
168
+ return { ok: true, value: { requested: true } };
169
+ }
170
+ catch (error) {
171
+ if (error instanceof PluginUpdatesRequestError)
172
+ return failure(error.code, error.message);
173
+ deps.log?.(`hiwork-core: 插件更新 RPC '${endpoint}' 失败(${error instanceof Error ? error.name : typeof error})`);
174
+ return failure('internal', PLUGIN_UPDATES_GENERIC_FAILURE_MESSAGE);
175
+ }
176
+ }
177
+ function failure(code, message) {
178
+ return { ok: false, error: { code, message, details: {} } };
179
+ }
180
+ /**
181
+ * 注册 `/hiwork-plugin-updates` 频道,返回异步 disposer(交给 `ctx.effect` 管理生命周期)。
182
+ */
183
+ export function registerPluginUpdatesRpc(ctx, deps) {
184
+ const handler = (endpoint, payload, signal) => handlePluginUpdatesEndpoint(endpoint, payload, deps, signal);
185
+ const remove = ctx.connection.rpc.handle(PLUGIN_UPDATES_RPC_CHANNEL, handler, { authority: 'loopback' });
186
+ return async () => { await remove(); };
187
+ }
@@ -247,6 +247,45 @@ export interface GatewayMcpAssignmentsView {
247
247
  revision: number;
248
248
  assignments: GatewayMcpAssignment[];
249
249
  }
250
+ /**
251
+ * GET /api/me/office-config 响应:本部署 OnlyOffice 集成的地址与共享 JWT 密钥。
252
+ *
253
+ * `jwtSecret` 是**共享的**一把密钥(Document Server 社区版只有一把),终端拿到它只为
254
+ * 签编辑器 config / 验保存回调;它只应在 host 侧使用,绝不进浏览器或日志。
255
+ */
256
+ export interface GatewayOfficeConfig {
257
+ documentServerUrl: string;
258
+ jwtSecret: string;
259
+ /**
260
+ * 是否启用文档中继(管理员在网关侧的中央开关)。true 时在线编辑走
261
+ * 「上传 → DS 从签名 URL 拉 → 回调落版本 → 桌面端轮询取回」,不再要求
262
+ * DS 能回连用户电脑。旧版网关缺省该字段 → 视同 false(自动回落本机模式)。
263
+ */
264
+ relayEnabled?: boolean;
265
+ }
266
+ /** POST /api/me/office-relay/documents 响应:DS 侧两条签名 URL。 */
267
+ export interface GatewayRelayDocument {
268
+ id: string;
269
+ documentKey: string;
270
+ readUrl: string;
271
+ callbackUrl: string;
272
+ expiresAt: string;
273
+ }
274
+ /** GET /api/me/office-relay/documents/:id 响应。 */
275
+ export interface GatewayRelayStatus {
276
+ state: string;
277
+ revision: number;
278
+ contentSha256: string;
279
+ sizeBytes: number;
280
+ expiresAt: string;
281
+ savedAt: string;
282
+ }
283
+ /** GET …/content 的结果:204 表示客户端手里的版本已是最新。 */
284
+ export interface GatewayRelayContent {
285
+ notModified: boolean;
286
+ revision: number;
287
+ bytes?: Uint8Array;
288
+ }
250
289
  /**
251
290
  * POST /api/me/mcp-assertions 响应:一张 **120 秒**的身份断言(RS256 JWT)+
252
291
  * 去哪换令牌。断言本身不是凭据(换不成令牌就是废纸),所以它可以出现在这里;
@@ -259,6 +298,96 @@ export interface GatewayMcpAssertionTicket {
259
298
  expiresAt: string;
260
299
  serverName: string;
261
300
  }
301
+ /**
302
+ * GET /api/me/skill-assignments 的一条分配(对齐 ai-hub spec §4.1 的冻结契约)。
303
+ *
304
+ * `skillName` 是**服务端权威**下发的装载名,也是受管目录名与模型侧 `/<名>` 的唯一来源;
305
+ * `contentSha256` 是「要不要重装」的唯一依据(`version` 只是给人看的标签,客户端**不得**
306
+ * 拿它比大小——它甚至是服务端派生值、库里没有这一列)。
307
+ */
308
+ export interface GatewaySkillAssignment {
309
+ assignmentId: string;
310
+ /** 单条版本号:管理员每次推送 +1。 */
311
+ revision: number;
312
+ /** 管理端唯一名(只服务 URL 与展示,不是装载名)。 */
313
+ slug: string;
314
+ displayName: string;
315
+ skillName: string;
316
+ description: string;
317
+ contentSha256: string;
318
+ sizeBytes: number;
319
+ /** 展示用标签;服务端可缺省,缺省时为空串。 */
320
+ version: string;
321
+ updatedAt: string;
322
+ }
323
+ /**
324
+ * GET /api/me/skill-assignments 响应。**响应体里没有包体**(清单与包体分开两条端点)。
325
+ * `revision` 是清单级序号(任何影响清单的写入都 +1),客户端先比它、不同再逐条深比。
326
+ */
327
+ export interface GatewaySkillAssignmentsView {
328
+ revision: number;
329
+ assignments: GatewaySkillAssignment[];
330
+ }
331
+ /**
332
+ * GET /api/me/skill-packages/:assignmentId 的结果:ZIP 原始字节 + 服务端声明的身份。
333
+ *
334
+ * `contentSha256` 取自响应头 `X-HiWork-Content-Sha256`,是**入库时算定**的那份哈希
335
+ * (spec §4.2:服务端只存取、不在响应时重新打包,否则 Go 版本一升级哈希就会漂,
336
+ * 客户端的内容校验会变成误报);客户端必须拿它与收到的字节比对后才落盘(spec §6)。
337
+ */
338
+ export interface GatewaySkillPackage {
339
+ skillName: string;
340
+ contentSha256: string;
341
+ bytes: Uint8Array;
342
+ }
343
+ /**
344
+ * GET /api/me/workbench-apps 里的一条应用(spec §4.1 的字段,`icon` 是网关侧的线名)。
345
+ *
346
+ * 这份声明刻意**宽松**(除 `code` 外全部可选,`executor` / `inputs` / `outputs` 用开放记录):
347
+ * 清单是**服务端演进**的,spec §4.1 的演进纪律要求「未知字段忽略、未知 `executor.kind` /
348
+ * `inputs[].type` 原样透传」。形状判据与逐字段规范化在消费端 `/hiwork-workbench` 的 Host 半边
349
+ * (`src/workbench-bridge.ts`)——本层只做传输与状态码映射,把 JSON 直接断言成契约类型。
350
+ */
351
+ export interface GatewayWorkbenchApp {
352
+ /** 应用唯一标识(管理端唯一名,也是卡片与服务端路径的坐标)。 */
353
+ code: string;
354
+ /** 单条版本号:管理员每次改动 +1。 */
355
+ revision?: number;
356
+ displayName?: string;
357
+ summary?: string;
358
+ /** 图标地址(网关线名就是 `icon`,不是 `iconUrl`;契约面的规范名见 `WorkbenchAppView`)。 */
359
+ icon?: string;
360
+ category?: {
361
+ code?: string;
362
+ name?: string;
363
+ };
364
+ tags?: string[];
365
+ featured?: boolean;
366
+ /**
367
+ * 执行器声明。`kind` 是**开放集合**(一期 `link` / `http-task`,二期 `session-task` /
368
+ * `embedded`):客户端遇到不认识的 kind 必须置灰 + 提示升级,绝不崩,所以这里不收敛成联合类型。
369
+ */
370
+ executor?: {
371
+ kind?: string;
372
+ mode?: string;
373
+ } & Record<string, unknown>;
374
+ /** 输入项(形状随 `type` 演进:`accept` / `options` / `default` 只对某些 type 有意义)。 */
375
+ inputs?: Array<Record<string, unknown>>;
376
+ outputs?: Array<Record<string, unknown>>;
377
+ /** `[]` | `['external']` | `['metered']`(只影响确认策略与风险提示,不改变契约形状)。 */
378
+ effects?: string[];
379
+ }
380
+ /** GET /api/me/workbench-apps 响应(spec §4.1)。 */
381
+ export interface GatewayWorkbenchAppsView {
382
+ /**
383
+ * 清单**整体**的契约版本(与 `revision` 不是一回事:前者是契约演进,后者是内容变更)。
384
+ * 本轮不据此拒收任何响应,原样透传给消费方自行判断。
385
+ */
386
+ schemaVersion?: number;
387
+ /** 清单级序号:服务端任何影响清单的写入都 +1(客户端先比它,不同再逐条深比)。 */
388
+ revision: number;
389
+ apps: GatewayWorkbenchApp[];
390
+ }
262
391
  /** 网关客户端端口:orchestration 只依赖此接口。 */
263
392
  export interface GatewayClient {
264
393
  /**
@@ -300,8 +429,24 @@ export interface GatewayClient {
300
429
  estimateCost(sessionCookie: string, items: GatewayCostEstimateItem[]): Promise<GatewayCostEstimateResponse>;
301
430
  /** 管理员下发给当前账号的 MCP 清单(响应里没有令牌)。 */
302
431
  getMyMcpAssignments(sessionCookie: string): Promise<GatewayMcpAssignmentsView>;
432
+ /** 本部署 OnlyOffice 集成配置(地址 + 共享 JWT 密钥 + 中继开关;部署未配置时 503)。 */
433
+ getMyOfficeConfig(sessionCookie: string): Promise<GatewayOfficeConfig>;
434
+ /** 上传文档到中继(原始字节 + 文件名头),拿回 DS 侧签名 URL。 */
435
+ createOfficeRelayDocument(sessionCookie: string, name: string, bytes: Uint8Array, mode?: 'view' | 'edit'): Promise<GatewayRelayDocument>;
436
+ /** 轮询中继会话状态(revision 单调递增即"有新版本")。 */
437
+ getOfficeRelayStatus(sessionCookie: string, id: string): Promise<GatewayRelayStatus>;
438
+ /** 取回当前版本字节;`revision` 用于"我手里已是这版"的短路(204)。 */
439
+ getOfficeRelayContent(sessionCookie: string, id: string, revision?: number): Promise<GatewayRelayContent>;
440
+ /** 关闭中继会话(立即清理字节)。 */
441
+ closeOfficeRelayDocument(sessionCookie: string, id: string): Promise<void>;
303
442
  /** 为某条分配签一张 120 秒的身份断言(拿它去 MCP 服务换 per-user 令牌)。 */
304
443
  issueMyMcpAssertion(sessionCookie: string, assignmentId: string): Promise<GatewayMcpAssertionTicket>;
444
+ /** 管理员下发给当前账号的技能清单(响应里没有包体——包体走 {@link getMySkillPackage})。 */
445
+ getMySkillAssignments(sessionCookie: string): Promise<GatewaySkillAssignmentsView>;
446
+ /** 取某条分配的技能包体(ZIP 字节 + 服务端声明的 sha256);分配不存在 404、未启用 503。 */
447
+ getMySkillPackage(sessionCookie: string, assignmentId: string): Promise<GatewaySkillPackage>;
448
+ /** 管理员登记并分配给当前账号的工作台应用清单(spec §4.1 的冻结契约;响应里没有令牌)。 */
449
+ getMyWorkbenchApps(sessionCookie: string): Promise<GatewayWorkbenchAppsView>;
305
450
  logout(sessionCookie: string): Promise<void>;
306
451
  }
307
452
  /**
@@ -375,6 +520,31 @@ export declare class AiRouterClient implements GatewayClient {
375
520
  * 清单(分 MCP 与分 Key 是两件事)。沿用它的 403 文案会把「没有 Key」错报成原因。
376
521
  */
377
522
  getMyMcpAssignments(sessionCookie: string): Promise<GatewayMcpAssignmentsView>;
523
+ /**
524
+ * POST /api/me/office-relay/documents:上传文档字节到中继。
525
+ *
526
+ * 文件名走 `X-Office-Document-Name` 头(HTTP 头只能带 ASCII,用 encodeURIComponent
527
+ * 编码;网关侧 PathUnescape 解码)。状态码映射成稳定 code,供插件层给用户文案。
528
+ */
529
+ createOfficeRelayDocument(sessionCookie: string, name: string, bytes: Uint8Array, mode?: 'view' | 'edit'): Promise<GatewayRelayDocument>;
530
+ /** GET /api/me/office-relay/documents/:id:轮询状态。 */
531
+ getOfficeRelayStatus(sessionCookie: string, id: string): Promise<GatewayRelayStatus>;
532
+ /**
533
+ * GET /api/me/office-relay/documents/:id/content[?v=N]:取回当前版本字节。
534
+ * 204 表示"你手里的版本已是最新"(不是错误)。
535
+ */
536
+ getOfficeRelayContent(sessionCookie: string, id: string, revision?: number): Promise<GatewayRelayContent>;
537
+ /** DELETE /api/me/office-relay/documents/:id:关闭会话(幂等)。 */
538
+ closeOfficeRelayDocument(sessionCookie: string, id: string): Promise<void>;
539
+ /**
540
+ * GET /api/me/office-config:本部署 OnlyOffice 集成(Document Server 地址 + 共享 JWT 密钥)。
541
+ *
542
+ * 与 {@link getMyMcpAssignments} 同样的理由不复用 `getMeJson`:那条路径把 403 映射成
543
+ * `NO_KEY_ASSIGNED`,而本端点只看会话(分集成配置与分 Key 是两件事)。503 是
544
+ * 「部署未配置」的稳定信号,单独映射——消费方(hiwork-office)据此提示联系管理员
545
+ * 并回退本地手工配置,而不是报一句含糊的「网关不可达」。
546
+ */
547
+ getMyOfficeConfig(sessionCookie: string): Promise<GatewayOfficeConfig>;
378
548
  /**
379
549
  * POST /api/me/mcp-assertions:为某条分配签一张 120 秒断言。
380
550
  *
@@ -383,6 +553,80 @@ export declare class AiRouterClient implements GatewayClient {
383
553
  * 「服务停用了」(该重试)还是「部署没配私钥」(找管理员)。
384
554
  */
385
555
  issueMyMcpAssertion(sessionCookie: string, assignmentId: string): Promise<GatewayMcpAssertionTicket>;
556
+ /**
557
+ * GET /api/me/skill-assignments:管理员下发给当前账号的技能清单(spec §4.1)。
558
+ *
559
+ * 与 {@link getMyMcpAssignments} 同样的理由不复用 `getMeJson`:那条路径把 403 映射成
560
+ * `NO_KEY_ASSIGNED`(「尚未被分配虚拟 KEY」),而本端点的鉴权只有会话——一个还没被分配 Key
561
+ * 的账号照样应该有技能清单(分技能与分 Key 是两件事)。
562
+ *
563
+ * **这条路径上只有 401 有独立语义,其余全部收敛成 `GATEWAY_UNREACHABLE`**,理由逐条:
564
+ * - 401 = 会话过期/缺失(`middleware` 的会话鉴权)——唯一一条服务端**真的会为这条路由**发出的
565
+ * 拒绝,也是唯一一条能让人做点什么(重新登录)的;
566
+ * - 403 不会出现:这条路由的鉴权只有会话,没有「有没有被分配虚拟 KEY」那一层(正因为如此,
567
+ * 这里才不能复用 `getMeJson`,那个函数会把 403 读成「没分配 KEY」);
568
+ * - 404 不是这条路由的响应形状:清单**永远**是 200 + 一份数组,某条技能被停用 / 被撤销是
569
+ * 「不在数组里」,不是 404(`MyAssignments` 逐条过滤,不会为单条抛错)。真收到 404 只可能是
570
+ * 「路径压根没被路由」——老网关没有这条路由的时候就是这个样子,而它与反代配错无法区分,
571
+ * 所以诚实读法是网关不可达,不是「管理员把技能全撤了」;
572
+ * - 503 不会出现:员工端这条路由**无条件注册**,服务端没有任何「技能下发未启用」的闸
573
+ * (`getMySkillPackage` 那条 503 也一样没有生产者,见那里的注释),整份响应由 service
574
+ * 直接产出;
575
+ * - 其余(5xx / 反代错误页 / 维护页)同理:没有可解释的拒绝语义,收敛成网关不可达。
576
+ *
577
+ * 形状校验(`assignments` 必须是数组)**不在这里**:本方法只做传输与状态码映射,把 JSON 直接
578
+ * 断言成契约类型。真正的形状判据在消费端 `/hiwork-managed-skills` 的 Host 半边
579
+ * (`src/managed-skills.ts` 的 `fetchOnce`)——那里才决定「这一份算不算成功拉到的清单」。
580
+ */
581
+ getMySkillAssignments(sessionCookie: string): Promise<GatewaySkillAssignmentsView>;
582
+ /**
583
+ * GET /api/me/skill-packages/:assignmentId:取一条分配的包体字节(spec §4.2)。
584
+ *
585
+ * **这条路径是「两个端口」里的另一条**,它才有独立的拒绝语义(与服务端 handler 一一对应):
586
+ * - 404 = 这条分配不在这个账号名下(已被撤销 / 从未属于它,`ErrSkillAssignmentNotForUser`
587
+ * ——技能被停用 / 分配被停用也走这一条,服务端刻意不区分),
588
+ * - 503 = 部署未启用技能下发(**保留的防御性映射:当前没有生产者**,见下)。
589
+ * 两者必须分开映射,否则客户端只会看到一句「拉包失败」。用途是**别把原因咽下去**:404 让人
590
+ * 停止重试、把原因显示出来(「这条分配已失效,稍后同步会重新核对」),503 让人知道该找管理员。
591
+ * ⚠️ 事实核对(spec §4.5):`SKILL_DISTRIBUTION_DISABLED` 全仓**只有本方法这一个生产方**
592
+ * (本方法里 503 那一支),而 ai-hub 目前**没有任何**会产生这个 503 的代码——员工端两条路由在
593
+ * `cmd/server/main.go` 里无条件注册,技能 / 分配被停用在员工端走 404
594
+ * (`ErrSkillAssignmentNotForUser`)。映射保留是**防御性 + 与 MCP 同形**(那里
595
+ * `MCP_ASSERTION_DISABLED` 真有生产者:`me_mcp_handler.go` 的 503),别拿它当「会发生的分支」
596
+ * 去设计行为。
597
+ * ⚠️ **这两条都只是「失败的原因」,不是删除本地受管技能的依据**:删除的唯一权柄是「成功拉到的
598
+ * 清单」(spec §6 的唯一安全不变量),由 Host 的清单端点产出。本文件里没有任何一处据 404 或
599
+ * `contentSha256` 去删本地技能(全仓 `SKILL_ASSIGNMENT_GONE` 只有生产方、类型联合、文案表与
600
+ * 测试,没有消费方)。
601
+ * 404 而不泄露「这条分配是否存在」是服务端的选择(§4.2 明确要 404 不要 403)。
602
+ *
603
+ * 本调用方从不发 `If-None-Match`(§4.2 说 304 那条是给管理端与 curl 排障用的),所以 304
604
+ * 不在这条路径上;真收到(反代插话)会落进下面那条 → 网关不可达。
605
+ *
606
+ * 包体**只在本函数里短暂存在**:交给 Host 侧频道(src/managed-skills.ts)后立即 base64
607
+ * 出去,网关会话 cookie 全程不出 Host,浏览器侧永远拿不到字节。
608
+ */
609
+ getMySkillPackage(sessionCookie: string, assignmentId: string): Promise<GatewaySkillPackage>;
610
+ /**
611
+ * GET /api/me/workbench-apps:管理员登记并分配给当前账号的工作台应用清单(spec §4.1)。
612
+ *
613
+ * 与技能清单同款**不复用 `getMeJson`**:那条路径把 403 映射成 `NO_KEY_ASSIGNED`(「尚未被分配
614
+ * 虚拟 KEY」),而本端点的鉴权只有会话——一个还没被分配 Key 的账号照样应该有工作台。
615
+ *
616
+ * 状态码映射(**与技能清单有一处刻意的不同**,理由在下面):
617
+ * - 401 = 会话过期/缺失:唯一一条能让人做点什么(重新登录)的拒绝;
618
+ * - **404 = 本部署未启用工作台**:这条路由在网关侧是「有配置才有」的(spec §10 的降级矩阵:
619
+ * 「网关未配置 workbench → 回 404」),因此 404 不是「应用清单为空」也不是网络故障,
620
+ * 而是一个可展示的功能状态——客户端据此显示「本部署尚未启用工作台」而不是报错。
621
+ * ⚠️ 技能清单那半**刻意**没有这一档(那里 404 只能是路径没被路由,收敛成网关不可达);
622
+ * 两边的差别是服务端语义的差别,不是实现风格的差别,别顺手对齐;
623
+ * - 其余(403 / 5xx / 反代错误页 / 维护页)一律 `GATEWAY_UNREACHABLE`:没有可解释的拒绝语义。
624
+ *
625
+ * 形状校验(`apps` 必须是数组)**不在这里**:本方法只做传输与状态码映射,把 JSON 直接断言成
626
+ * 契约类型;真正的形状判据在消费端 `/hiwork-workbench` 的 Host 半边(`src/workbench-bridge.ts`),
627
+ * 那里才决定「这一份算不算成功拉到的清单」。
628
+ */
629
+ getMyWorkbenchApps(sessionCookie: string): Promise<GatewayWorkbenchAppsView>;
386
630
  /**
387
631
  * /api/me 自助端点的 GET 公共路径:网络异常 → GATEWAY_UNREACHABLE,
388
632
  * 401/403/400 映射为稳定 code,响应体非 JSON 时不把原文带进异常。
@@ -22,6 +22,8 @@
22
22
  */
23
23
  import type { FeatureRegistry } from './feature-registry.ts';
24
24
  import type { ManagedMcpAssertion, ManagedMcpSnapshot } from './managed-mcp.ts';
25
+ import type { ManagedSkillsFace } from './managed-skills.ts';
26
+ import type { ManagedWorkbenchFace } from './managed-workbench.ts';
25
27
  /** cordis 服务名:`hiwork-core` 的 client 半边提供,业务插件按名注入。 */
26
28
  export declare const HIWORK_FEATURE_CENTER_SERVICE = "hiworkFeatureCenter";
27
29
  /**
@@ -63,4 +65,40 @@ export interface HiWorkCoreClientFace {
63
65
  /** 订阅镜像变化(登出 / 换账号时会被清空)。返回退订函数。 */
64
66
  readonly subscribe: (listener: () => void) => () => void;
65
67
  };
68
+ /**
69
+ * 管理员下发的技能(技能域消费,见 `src/client/managed-skills.ts`;契约见 ai-hub 的
70
+ * spec `docs/superpowers/specs/2026-09-17-skill-distribution-design.md` §4.5)。
71
+ *
72
+ * **可选成员**(与 `managedMcp` 同款的可选解耦):新 core 遇到老 capabilities 只是
73
+ * **没人来取**(能力中心照常工作,只是没有受管技能那一块);老 core 遇到新 capabilities
74
+ * 只是**没有这个面**(`face.managedSkills` 为 `undefined`,消费方按「这个部署不支持受管
75
+ * 技能」降级)。两侧因此不必严格同发一个版本——发布顺序(spec §7)允许 core 先发。
76
+ *
77
+ * 这一面**只交出清单与包体字节**:技能链路没有服务端调用、没有凭证,所以既没有断言,
78
+ * 也没有任何票据;落盘与完整性校验在技能域,核心既不落盘也不解包。
79
+ *
80
+ * ⚠️ **`snapshot` 不参与同步**:它可能是上一次成功的结果(管理员刚撤掉的分配还在里面),
81
+ * 拿它做删除决策就会把「管理员撤销了」误做成「这次没拉到」——删除只允许走 `refresh`
82
+ * 成功返回的清单(spec §6 的唯一安全不变量,`src/client/managed-mcp.ts:9-12` 同款)。
83
+ */
84
+ readonly managedSkills?: ManagedSkillsFace;
85
+ /**
86
+ * 管理员登记并分配给当前账号的**工作台应用清单**(工作台域消费,见 `src/client/managed-workbench.ts`;
87
+ * 契约见 hiwork-workbench 的 spec `docs/superpowers/specs/2026-09-17-hiwork-workbench-design.md`
88
+ * §4.1 / §4.5)。
89
+ *
90
+ * **可选成员**(与 `managedMcp` / `managedSkills` 同款的两级可选语义):新 core 遇到老
91
+ * workbench 只是**没人来取**;老 core 遇到新 workbench 只是**没有这个面**
92
+ * (`face.managedWorkbench` 为 `undefined`,工作台按「本部署尚未启用工作台」降级,spec §10)。
93
+ * 两侧因此不必严格同发一个版本——发布顺序(spec §11 的硬约束)允许 core 先发。
94
+ *
95
+ * **M0 只交出清单**:本面没有 `submit` / `poll` / `cancel` / `materialize`(那是 M1 的任务运行时,
96
+ * spec §4.5 的完整面在那时才补齐)。工作台消费方据此判断能力:这几个成员不存在,就是
97
+ * 「这个 core 只能看货架」。
98
+ *
99
+ * ⚠️ **`snapshot` 不参与下架决策**:它可能是上一次成功的结果(管理员刚撤掉的条目还在里面),
100
+ * 拿它做删除决策就会把「管理员撤销了」误做成「这次没拉到」——「这个应用不在了」只允许走
101
+ * `refresh` 成功返回的清单(spec §4.5 的冻结纪律,`src/client/managed-mcp.ts:9-12` 同款)。
102
+ */
103
+ readonly managedWorkbench?: ManagedWorkbenchFace;
66
104
  }