dsh-code-server-app 0.3.20 → 0.3.22

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.
Files changed (42) hide show
  1. package/README.en.md +82 -19
  2. package/README.md +74 -21
  3. package/assets/extensions/dshcs-editor-bridge/extension.js +120 -64
  4. package/assets/extensions/dshcs-editor-bridge/lib/ask-panel.js +343 -174
  5. package/assets/extensions/dshcs-editor-bridge/lib/bridge-client.js +30 -4
  6. package/assets/extensions/dshcs-editor-bridge/package.json +2 -2
  7. package/assets/extensions/dshcs-editor-bridge/webview/THIRD-PARTY.md +21 -0
  8. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_AMS-Regular-U6PRYMIZ.woff2 +0 -0
  9. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Caligraphic-Bold-5QL5CMTE.woff2 +0 -0
  10. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Caligraphic-Regular-KX5MEWCF.woff2 +0 -0
  11. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Fraktur-Bold-2QVFK6NQ.woff2 +0 -0
  12. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Fraktur-Regular-2PEIFJSJ.woff2 +0 -0
  13. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Main-Bold-YP5VVQRP.woff2 +0 -0
  14. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Main-BoldItalic-N4V3DX7S.woff2 +0 -0
  15. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Main-Italic-RELBIK7M.woff2 +0 -0
  16. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Main-Regular-ARRPAO67.woff2 +0 -0
  17. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Math-BoldItalic-K4WTGH3J.woff2 +0 -0
  18. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Math-Italic-6KGCHLFN.woff2 +0 -0
  19. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_SansSerif-Bold-RRNVJFFW.woff2 +0 -0
  20. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_SansSerif-Italic-HMPFTM52.woff2 +0 -0
  21. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_SansSerif-Regular-XIQ62X4E.woff2 +0 -0
  22. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Script-Regular-APUWIHLP.woff2 +0 -0
  23. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Size1-Regular-5LRUTBFT.woff2 +0 -0
  24. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Size2-Regular-LELKET5D.woff2 +0 -0
  25. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Size3-Regular-WQRQ47UD.woff2 +0 -0
  26. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Size4-Regular-CDMV7U5C.woff2 +0 -0
  27. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Typewriter-Regular-VBYJ4NRC.woff2 +0 -0
  28. package/assets/extensions/dshcs-editor-bridge/webview/src/app.jsx +189 -0
  29. package/assets/extensions/dshcs-editor-bridge/webview/src/approval.jsx +53 -0
  30. package/assets/extensions/dshcs-editor-bridge/webview/src/official-tokens.css +8 -0
  31. package/assets/extensions/dshcs-editor-bridge/webview/src/panel.css +283 -0
  32. package/assets/extensions/dshcs-editor-bridge/webview/src/thread.jsx +72 -0
  33. package/assets/extensions/dshcs-editor-bridge/webview/thread.css +1 -0
  34. package/assets/extensions/dshcs-editor-bridge/webview/thread.js +462 -0
  35. package/lib/bridge-approval.mjs +189 -0
  36. package/lib/bridge-session.mjs +3 -1
  37. package/lib/bridge-thread.mjs +345 -0
  38. package/lib/bridge.mjs +15 -6
  39. package/lib/index.js +142 -17
  40. package/package.json +29 -6
  41. package/vendor/VENDOR.json +1 -1
  42. package/lib/bridge-answer.mjs +0 -155
@@ -0,0 +1,189 @@
1
+ /**
2
+ * lib/bridge-approval.mjs — 让编辑器面板也能就地处理 DSH 的授权请求(0.3.22)。
3
+ *
4
+ * ## 为什么需要
5
+ * DSH 把"敏感动作需要人确认"收敛到 `@deepseek-ai/dsh-user-approval` 的 `approval` 服务:
6
+ * `approval/request` 是一个 **agent 作用域的 waterfall 事件**,答案方可以是服务端监听器,
7
+ * 也可以是通过 Remote 参与进来的**浏览器客户端**(官方 `ui-approval` 卡片,
8
+ * `packages/api/remotes/src/remote-events.ts:18` 把它注册为 waterfall 远程事件)。
9
+ * outcome 只有四种:`allowed-once | rejected | cancelled | unavailable`,**没有答案者就 fail closed**。
10
+ * 于是"从编辑器里提问"的场景会撞上一堵墙:agent 想**写工作区外的文件**或**执行命令**时,
11
+ * 授权卡片只出现在 DSH 界面里 —— 而用户人在编辑器,既看不到、也可能因为超时而失败关闭。
12
+ *
13
+ * ## 策略(先面板、后官方;绝不自动放行)
14
+ * 只对**被面板 watch 的会话**注册监听(`agent.ctx.on('approval/request', …)`,agent 作用域天然只收本会话):
15
+ * 1. 生成 pending 记录 → 面板随 `/sync` 的 `approvals` 字段看到卡片(工具名 / 原因 / 参数摘要);
16
+ * 2. 等面板决策,最多 `HOLD_MS`(默认 8s):
17
+ * - 窗口内作答 → **返回该 outcome**(settle;官方卡片不出现,人在编辑器就地处理);
18
+ * - 无人作答 / 面板没开 → `return next()`,**原样交给官方链路**(DSH 界面照旧弹卡);
19
+ * 3. 永不返回自动授权:`allowed-once` 只能来自用户点击。
20
+ *
21
+ * ## 安全
22
+ * 面板的决策只能通过桥的 `POST /approve` 进来(令牌 + 带 Origin 一律 403),而且:
23
+ * - `id` 必须是**本进程发起、仍未决**的请求(单次使用,用后即废);
24
+ * - outcome 只接受 `allowed-once` 与 `rejected` 两个白名单值;
25
+ * - 不接受任何自由文本 / 路径 / 命令参数 —— 这条路由**只能回答既有的问题**,不能发起动作。
26
+ */
27
+
28
+ /** 面板优先的等待窗口(毫秒);超过就交给官方链路。 */
29
+ export const DEFAULT_HOLD_MS = 8000;
30
+
31
+ /** 同时挂起的授权请求上限(超了直接交给官方,避免堆积)。 */
32
+ export const MAX_PENDING = 4;
33
+
34
+ /** 允许的面板决策值(白名单;其余一律拒绝)。 */
35
+ export const PANEL_OUTCOMES = ['allowed-once', 'rejected'];
36
+
37
+ /** 造一个 pending 注册表(纯逻辑,可单测)。 */
38
+ export function createApprovalBoard({ holdMs = DEFAULT_HOLD_MS, maxPending = MAX_PENDING, now = () => Date.now() } = {}) {
39
+ /** requestId → {id, toolName, callId, reason, at, settle} */
40
+ const pending = new Map();
41
+
42
+ return {
43
+ size() {
44
+ return pending.size;
45
+ },
46
+ /** 面板要显示的待决列表(纯 JSON)。 */
47
+ snapshot() {
48
+ return [...pending.values()].map((item) => ({
49
+ id: item.id,
50
+ toolName: item.toolName,
51
+ callId: item.callId ?? null,
52
+ reason: item.reason ?? '',
53
+ at: item.at,
54
+ }));
55
+ },
56
+ get(id) {
57
+ return pending.get(id) ?? null;
58
+ },
59
+ /** 登记一条请求,返回它的 Promise(面板点击前一直挂着)。 */
60
+ open({ id, toolName, callId, reason }) {
61
+ if (typeof id !== 'string' || id === '' || pending.has(id)) return null;
62
+ if (pending.size >= maxPending) return null;
63
+ let settle;
64
+ const decision = new Promise((resolve) => { settle = resolve; });
65
+ pending.set(id, { id, toolName, callId: callId ?? null, reason: reason ?? '', at: now(), settle });
66
+ return decision;
67
+ },
68
+ /** 面板作答:只接受白名单值;未知 / 已决 → false。 */
69
+ answer(id, outcome) {
70
+ const item = typeof id === 'string' ? pending.get(id) : undefined;
71
+ if (item === undefined) return false;
72
+ if (!PANEL_OUTCOMES.includes(outcome)) return false;
73
+ pending.delete(id);
74
+ item.settle(outcome);
75
+ return true;
76
+ },
77
+ /** 窗口到点 / 面板关闭:把这条请求撤出面板(调用方随后 `next()` 交给官方)。 */
78
+ drop(id) {
79
+ const item = typeof id === 'string' ? pending.get(id) : undefined;
80
+ if (item === undefined) return false;
81
+ pending.delete(id);
82
+ item.settle(undefined);
83
+ return true;
84
+ },
85
+ /** 丢弃全部(卸载 / 面板关闭)。 */
86
+ clear() {
87
+ for (const id of [...pending.keys()]) this.drop(id);
88
+ },
89
+ };
90
+ }
91
+
92
+ /**
93
+ * 注册授权拦截。
94
+ *
95
+ * @param {object} deps
96
+ * @param {ReturnType<typeof createApprovalBoard>} deps.board
97
+ * @param {(message: string) => void} [deps.log]
98
+ * @param {number} [deps.holdMs]
99
+ * @param {() => boolean} deps.hasPanel 面板是否在看着(关掉时立刻交给官方,不做无谓等待)
100
+ * @returns {{intercept: (agent: object, sessionId: string) => () => void}}
101
+ */
102
+ export function createApprovalInterceptor(deps) {
103
+ const board = deps.board;
104
+ const log = deps.log ?? (() => {});
105
+ const holdMs = deps.holdMs ?? DEFAULT_HOLD_MS;
106
+ const hasPanel = deps.hasPanel ?? (() => true);
107
+ /** agent → disposer(每个被 watch 的 agent 一个监听器) */
108
+ const registered = new Map();
109
+
110
+ async function handle(request, next) {
111
+ try {
112
+ // 面板没在看:完全不拦截,保持今天的行为。
113
+ if (!hasPanel()) return await next();
114
+ const id = typeof request?.id === 'string' ? request.id : null;
115
+ if (id === null) return await next();
116
+ const decision = board.open({
117
+ id,
118
+ toolName: typeof request.toolName === 'string' ? request.toolName : 'tool',
119
+ callId: request.callId ?? null,
120
+ reason: typeof request.reason === 'string' ? request.reason : '',
121
+ });
122
+ if (decision === null) {
123
+ // 上限已满 / id 重复:不抢,交给官方。
124
+ return await next();
125
+ }
126
+ let timer = null;
127
+ const timeout = new Promise((resolve) => {
128
+ timer = setTimeout(() => resolve(undefined), holdMs);
129
+ if (typeof timer.unref === 'function') timer.unref();
130
+ });
131
+ const outcome = await Promise.race([decision, timeout]);
132
+ if (timer !== null) clearTimeout(timer);
133
+ if (typeof outcome === 'string' && PANEL_OUTCOMES.includes(outcome)) {
134
+ board.drop(id); // 幂等:answer() 已删过也没关系
135
+ log(`授权由编辑器面板决定:${request.toolName ?? 'tool'} → ${outcome}`);
136
+ return outcome;
137
+ }
138
+ board.drop(id);
139
+ log(`授权 ${request.toolName ?? 'tool'} 面板未作答(${holdMs}ms),交给 DSH 界面`);
140
+ return await next();
141
+ } catch (err) {
142
+ // 拦截器出问题绝不能把授权变成"放行":退回官方链路(它自己会 fail closed)。
143
+ log(`授权拦截异常,交回官方链路:${err && err.message ? err.message : String(err)}`);
144
+ return await next();
145
+ }
146
+ }
147
+
148
+ return {
149
+ /**
150
+ * 为某个 agent 注册拦截(agent 作用域 ⇒ 只收到该会话的请求)。
151
+ * @returns {() => void} disposer(永不抛)
152
+ */
153
+ intercept(agent, sessionId) {
154
+ if (agent === null || typeof agent !== 'object') return () => {};
155
+ const scope = agent.ctx ?? null;
156
+ if (scope === null || typeof scope.on !== 'function') return () => {};
157
+ if (registered.has(agent)) return registered.get(agent);
158
+ let dispose = () => {};
159
+ try {
160
+ const result = scope.on('approval/request', handle);
161
+ if (typeof result === 'function') dispose = result;
162
+ } catch (err) {
163
+ log(`注册授权拦截失败(${sessionId}):${err && err.message ? err.message : String(err)}`);
164
+ return () => {};
165
+ }
166
+ const wrapped = () => {
167
+ registered.delete(agent);
168
+ board.clear();
169
+ try {
170
+ dispose();
171
+ } catch {
172
+ // 注销失败无关正确性
173
+ }
174
+ };
175
+ registered.set(agent, wrapped);
176
+ log(`授权拦截已启用(会话 ${sessionId},面板优先 ${holdMs}ms,之后交给 DSH 界面)`);
177
+ return wrapped;
178
+ },
179
+ /** 当前注册的 agent 数(诊断 / 测试)。 */
180
+ size() {
181
+ return registered.size;
182
+ },
183
+ /** 全部撤销。 */
184
+ dispose() {
185
+ for (const dispose of [...registered.values()]) dispose();
186
+ registered.clear();
187
+ },
188
+ };
189
+ }
@@ -143,5 +143,7 @@ export async function deliverEditorPrompt(ctx, input) {
143
143
  const sessionId = agent.session !== undefined && agent.session !== null && typeof agent.session === 'object'
144
144
  ? (agent.session.id ?? agent.id ?? null)
145
145
  : (agent.id ?? null);
146
- return { ok: true, sessionId, text };
146
+ // `agent` 是给调用方用的**内部句柄**(授权拦截要拿 `agent.ctx` 注册 agent 作用域监听),
147
+ // 路由返回前必须剥掉:它不可 JSON 序列化,也不该泄漏进编辑器。
148
+ return { ok: true, sessionId, text, agent };
147
149
  }
@@ -0,0 +1,345 @@
1
+ /**
2
+ * lib/bridge-thread.mjs — 把「被面板观看的会话」投影成有界的对话条目流(0.3.22)。
3
+ *
4
+ * 方向:DSH → 编辑器。编辑器里的「问 DSH」面板要像 DSH 对话那样显示内容,数据来自
5
+ * **正规的实时通道** `sessionController.follow()`(冷安全:开帧给 `cursor`,之后是
6
+ * gap-free 的耐久事件帧 + 无游标的助手流帧)。
7
+ *
8
+ * 为什么不用别的(都核查过):
9
+ * - 同步读会话历史(`Session.snapshotEvents/eventAt/ownEvents`)已被 DSH 明令禁止新增调用
10
+ * (`.agents/notes/.../2026-09-09-deprecate-synchronous-session-event-reads.zh.md`);
11
+ * - `sessionController.page()` 是**历史分页** —— 本版本按用户要求**只渲染新内容**,不调用它;
12
+ * - 投影(`turnOutline` 等)只给有界预览(50/120 字符),不够渲染正文。
13
+ *
14
+ * `follow` 的帧形状(源码实证,`packages/api/session-controller/src/types.ts:515`):
15
+ * | { type:'snapshot'; cursor; records[]; assistantStream? } ← 开帧(历史,本版本**丢弃**)
16
+ * | { type:'event'; event: SessionWireEvent } ← 耐久事件
17
+ * | { type:'assistant-stream'; frame: { type:'start'|'chunk'; turn; chunk } }
18
+ *
19
+ * 有界:每会话 ≤ `MAX_ENTRIES` 条、单条正文 ≤ `MAX_TEXT`、同时 watch ≤ `MAX_WATCHED`(LRU + abort)。
20
+ */
21
+
22
+ /** 每个会话保留的条目上限(丢最旧的;更早的内容本版本不提供)。 */
23
+ export const MAX_ENTRIES = 120;
24
+
25
+ /** 单条正文上限(超出截断并标注)。 */
26
+ export const MAX_TEXT = 8000;
27
+
28
+ /** 同时 watch 的会话上限。 */
29
+ export const MAX_WATCHED = 4;
30
+
31
+ /** 工具参数 / 原因摘要上限。 */
32
+ export const MAX_SUMMARY = 160;
33
+
34
+ /** 截断到上限并标注。 */
35
+ function clip(text, limit = MAX_TEXT) {
36
+ if (typeof text !== 'string') return '';
37
+ return text.length > limit ? `${text.slice(0, limit)}…(已截断)` : text;
38
+ }
39
+
40
+ /** 从消息内容块里取正文(text 块拼接;reasoning / tool-call 不进面板)。 */
41
+ export function textOfContent(content) {
42
+ if (!Array.isArray(content)) return '';
43
+ const parts = [];
44
+ for (const block of content) {
45
+ if (block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
46
+ parts.push(block.text);
47
+ }
48
+ }
49
+ return clip(parts.join(''));
50
+ }
51
+
52
+ /** 工具调用摘要:从原始 JSON 参数里抽一个最有信息量的字段(路径 / 命令首行)。 */
53
+ export function summarizeToolArguments(rawArguments) {
54
+ if (typeof rawArguments !== 'string' || rawArguments.trim() === '') return '';
55
+ let args;
56
+ try {
57
+ args = JSON.parse(rawArguments);
58
+ } catch {
59
+ return clip(rawArguments, MAX_SUMMARY);
60
+ }
61
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) return '';
62
+ for (const key of ['file_path', 'path', 'command', 'pattern', 'query', 'url', 'prompt']) {
63
+ const value = args[key];
64
+ if (typeof value === 'string' && value.trim() !== '') return clip(value.split('\n')[0], MAX_SUMMARY);
65
+ }
66
+ const keys = Object.keys(args);
67
+ return keys.length === 0 ? '' : clip(keys.slice(0, 4).join(', '), MAX_SUMMARY);
68
+ }
69
+
70
+ /** 一个会话的投影状态(纯数据,便于单测)。 */
71
+ export function createThreadState(sessionId) {
72
+ return {
73
+ sessionId,
74
+ entries: [],
75
+ /** tool/call 的 callId → entries 下标(用于 result 回填)。 */
76
+ callIndex: new Map(),
77
+ /** 流式条目在 entries 里的下标(没有则 null)。 */
78
+ streamingIndex: null,
79
+ /** 流式条目属于哪一轮;换轮时新建条目。 */
80
+ streamingTurn: null,
81
+ error: null,
82
+ };
83
+ }
84
+
85
+ /** 重建 callId / 流式条目索引(条目数很小,整表扫一遍最不容易错)。 */
86
+ function rebuildIndex(state) {
87
+ state.callIndex.clear();
88
+ state.streamingIndex = null;
89
+ for (let i = 0; i < state.entries.length; i += 1) {
90
+ const item = state.entries[i];
91
+ if (item.callId !== undefined) state.callIndex.set(String(item.callId), i);
92
+ if (item.streaming === true) state.streamingIndex = i;
93
+ }
94
+ }
95
+
96
+ /** 追加一条条目;超上限时丢最旧。 */
97
+ function push(state, entry) {
98
+ state.entries.push(entry);
99
+ if (state.entries.length > MAX_ENTRIES) state.entries.shift();
100
+ rebuildIndex(state);
101
+ return state.entries.length - 1;
102
+ }
103
+
104
+ /** 助手流帧(SessionAssistantStreamFrame):只取正文 text-delta。 */
105
+ export function applyStreamFrame(state, frame) {
106
+ if (frame === null || typeof frame !== 'object' || frame.type !== 'chunk') return false;
107
+ const chunk = frame.chunk;
108
+ if (chunk === null || typeof chunk !== 'object' || chunk.type !== 'text-delta' || typeof chunk.text !== 'string') return false;
109
+ const turn = Number.isSafeInteger(frame.turn) ? frame.turn : null;
110
+ if (state.streamingIndex !== null && state.streamingTurn === turn) {
111
+ const current = state.entries[state.streamingIndex];
112
+ current.text = clip(current.text + chunk.text);
113
+ return true;
114
+ }
115
+ state.streamingIndex = push(state, { role: 'assistant', text: clip(chunk.text), streaming: true });
116
+ state.streamingTurn = turn;
117
+ return true;
118
+ }
119
+
120
+ /**
121
+ * 把一个会话事件投影进状态。
122
+ * @param {ReturnType<typeof createThreadState>} state
123
+ * @param {{type: string, seq?: number, data?: object}} event `SessionWireEvent`
124
+ * @returns {boolean} 是否变化
125
+ */
126
+ export function applyEvent(state, event) {
127
+ if (state === null || event === null || typeof event !== 'object') return false;
128
+ const data = event.data ?? {};
129
+
130
+ if (event.type === 'user/message') {
131
+ const text = textOfContent(data.content);
132
+ if (text === '') return false;
133
+ push(state, { role: 'user', text });
134
+ return true;
135
+ }
136
+
137
+ if (event.type === 'assistant/message') {
138
+ const text = textOfContent(data.message?.content);
139
+ const streamingIndex = state.streamingIndex;
140
+ state.streamingIndex = null;
141
+ state.streamingTurn = null;
142
+ if (streamingIndex !== null && state.entries[streamingIndex] !== undefined) {
143
+ // 耐久消息落地:原地替换同一轮的流式临时条目(不重复显示,顺序也不变)。
144
+ if (text === '') {
145
+ state.entries.splice(streamingIndex, 1);
146
+ rebuildIndex(state);
147
+ return true;
148
+ }
149
+ state.entries[streamingIndex] = { role: 'assistant', text };
150
+ rebuildIndex(state);
151
+ return true;
152
+ }
153
+ if (text === '') return false;
154
+ push(state, { role: 'assistant', text });
155
+ return true;
156
+ }
157
+
158
+ if (event.type === 'tool/call') {
159
+ const callId = typeof data.callId === 'string' ? data.callId : undefined;
160
+ const index = push(state, {
161
+ role: 'tool',
162
+ callId,
163
+ name: typeof data.name === 'string' ? data.name : 'tool',
164
+ summary: summarizeToolArguments(data.arguments),
165
+ status: 'running',
166
+ });
167
+ if (callId !== undefined) state.callIndex.set(callId, index);
168
+ return true;
169
+ }
170
+
171
+ if (event.type === 'tool/result') {
172
+ const callId = data.message?.source?.callId;
173
+ const index = callId === undefined ? undefined : state.callIndex.get(String(callId));
174
+ if (index === undefined) return false;
175
+ const block = Array.isArray(data.message?.content) ? data.message.content[0] : undefined;
176
+ state.entries[index].status = block !== undefined && block.isError === true ? 'error' : 'ok';
177
+ return true;
178
+ }
179
+
180
+ // 授权审计(会话事件,log-only):面板据此显示"已授权一次 / 已拒绝"。
181
+ if (event.type === 'approval/asked') {
182
+ push(state, {
183
+ role: 'approval',
184
+ approvalId: typeof data.id === 'string' ? data.id : undefined,
185
+ toolName: typeof data.toolName === 'string' ? data.toolName : 'tool',
186
+ summary: typeof data.reason === 'string' ? clip(data.reason, MAX_SUMMARY) : '',
187
+ status: 'asked',
188
+ });
189
+ return true;
190
+ }
191
+
192
+ if (event.type === 'approval/decided') {
193
+ const id = typeof data.id === 'string' ? data.id : undefined;
194
+ for (let i = state.entries.length - 1; i >= 0; i -= 1) {
195
+ const item = state.entries[i];
196
+ if (item.role === 'approval' && (id === undefined || item.approvalId === id)) {
197
+ item.status = typeof data.outcome === 'string' ? data.outcome : 'unavailable';
198
+ return true;
199
+ }
200
+ }
201
+ return false;
202
+ }
203
+
204
+ return false;
205
+ }
206
+
207
+ /**
208
+ * 收一帧 `follow` 输出。
209
+ * 开帧(`snapshot`)只取 `cursor` 与助手流基线 —— 历史记录按"只渲染新内容"丢弃。
210
+ */
211
+ export function consumeFrame(state, frame) {
212
+ if (state === null || frame === null || typeof frame !== 'object') return false;
213
+ if (frame.type === 'snapshot') {
214
+ state.cursor = Number.isSafeInteger(frame.cursor) ? frame.cursor : state.cursor ?? 0;
215
+ return false; // 不渲染历史
216
+ }
217
+ if (frame.type === 'assistant-stream') return applyStreamFrame(state, frame.frame);
218
+ if (frame.type === 'event') return applyEvent(state, frame.event);
219
+ // 兼容:调用方直接喂会话事件(测试与降级路径)
220
+ if (typeof frame.type === 'string' && frame.data !== undefined) return applyEvent(state, frame);
221
+ return false;
222
+ }
223
+
224
+ /** 给 `/sync` 的快照(纯 JSON)。 */
225
+ export function threadSnapshot(state) {
226
+ return {
227
+ cursor: Number.isSafeInteger(state.cursor) ? state.cursor : null,
228
+ entries: state.entries.map((entry) => ({
229
+ role: entry.role,
230
+ text: entry.text ?? null,
231
+ streaming: entry.streaming === true,
232
+ name: entry.name ?? null,
233
+ callId: entry.callId ?? null,
234
+ summary: entry.summary ?? null,
235
+ status: entry.status ?? null,
236
+ approvalId: entry.approvalId ?? null,
237
+ })),
238
+ };
239
+ }
240
+
241
+ /**
242
+ * watch 注册表。
243
+ *
244
+ * @param {{resolveController: () => object|undefined, log?: (message: string) => void,
245
+ * maxWatched?: number}} deps `resolveController()` 每次现取(host 侧一律 `ctx.get`)。
246
+ */
247
+ export function createThreadRegistry(deps) {
248
+ const log = deps.log ?? (() => {});
249
+ const maxWatched = deps.maxWatched ?? MAX_WATCHED;
250
+ /** sessionId → {state, abort} */
251
+ const watched = new Map();
252
+
253
+ function controller() {
254
+ try {
255
+ return deps.resolveController();
256
+ } catch (err) {
257
+ log(`取 sessionController 失败:${err && err.message ? err.message : String(err)}`);
258
+ return undefined;
259
+ }
260
+ }
261
+
262
+ function unwatch(sessionId) {
263
+ const entry = watched.get(sessionId);
264
+ if (entry === undefined) return false;
265
+ watched.delete(sessionId);
266
+ try {
267
+ entry.abort.abort();
268
+ } catch {
269
+ // abort 失败不影响正确性
270
+ }
271
+ return true;
272
+ }
273
+
274
+ async function run(sessionId, entry, signal) {
275
+ try {
276
+ const service = entry.service;
277
+ const stream = service.follow(
278
+ { address: { kind: 'session', sessionId }, maxMessages: 50, assistantStream: true },
279
+ signal,
280
+ );
281
+ for await (const frame of stream) {
282
+ if (signal.aborted) break;
283
+ consumeFrame(entry.state, frame);
284
+ }
285
+ } catch (err) {
286
+ if (!signal.aborted) {
287
+ entry.state.error = `会话订阅失败:${err && err.message ? err.message : String(err)}`;
288
+ log(`会话 ${sessionId} 订阅结束:${entry.state.error}`);
289
+ }
290
+ }
291
+ }
292
+
293
+ function watch(sessionId) {
294
+ if (typeof sessionId !== 'string' || sessionId === '') return false;
295
+ if (watched.has(sessionId)) return true;
296
+ const service = controller();
297
+ if (service === undefined || typeof service.follow !== 'function') return false;
298
+ while (watched.size >= maxWatched) {
299
+ const oldest = watched.keys().next().value;
300
+ log(`watch 超过 ${maxWatched} 个,丢弃最旧的会话 ${String(oldest)}`);
301
+ unwatch(oldest);
302
+ }
303
+ const abort = new AbortController();
304
+ const entry = { service, state: createThreadState(sessionId), abort };
305
+ watched.set(sessionId, entry);
306
+ void run(sessionId, entry, abort.signal);
307
+ return true;
308
+ }
309
+
310
+ return {
311
+ watch,
312
+ unwatch,
313
+ /** 按扩展声明的 watch 列表对齐(多余的停止、缺的建立)。 */
314
+ sync(sessionIds) {
315
+ if (!Array.isArray(sessionIds)) return;
316
+ const wanted = new Set(sessionIds.filter((id) => typeof id === 'string' && id !== ''));
317
+ for (const id of [...watched.keys()]) if (!wanted.has(id)) unwatch(id);
318
+ for (const id of wanted) watch(id);
319
+ },
320
+ /** 快照:返回面板关心的那个会话(数组首个);没 watch 到就返回空壳。 */
321
+ snapshot(sessionIds) {
322
+ const wanted = Array.isArray(sessionIds) ? sessionIds : [...watched.keys()];
323
+ for (const id of wanted) {
324
+ const entry = watched.get(id);
325
+ if (entry !== undefined) {
326
+ return { available: true, sessionId: id, ...threadSnapshot(entry.state), error: entry.state.error ?? null };
327
+ }
328
+ }
329
+ return { available: watched.size > 0, sessionId: null, cursor: null, entries: [], error: null };
330
+ },
331
+ /** 有没有可用的会话服务(面板据此区分"宿主没能力"与"没在 watch")。 */
332
+ supported() {
333
+ const service = controller();
334
+ return service !== undefined && typeof service.follow === 'function';
335
+ },
336
+ /** 该会话是否在 watch 中(授权拦截据此判断"面板是否在看")。 */
337
+ isWatched(sessionId) {
338
+ return watched.has(sessionId);
339
+ },
340
+ /** 卸载:abort 全部订阅。 */
341
+ dispose() {
342
+ for (const id of [...watched.keys()]) unwatch(id);
343
+ },
344
+ };
345
+ }
package/lib/bridge.mjs CHANGED
@@ -31,13 +31,22 @@
31
31
  *
32
32
  * ## 安全不变量(改这个文件之前先读这四条)
33
33
  *
34
- * 1. **`BRIDGE_BASE`(`/code-server-bridge`)命名空间永久只读。** 不允许出现任何写文件、改文档、执行命令、
35
- * 拉起进程的路由 —— `bridge.json` 里那个令牌对本机同用户进程可读,爆炸半径必须封在
36
- * "泄露编辑器里的信息",绝不能变成任意文件写 / 任意命令执行。
37
- * 2. **带 `Origin` 的请求一律 403。** 浏览器发起的请求必带 Origin,扩展宿主进程不带。
34
+ * 1. **`BRIDGE_BASE`(`/code-server-bridge`)命名空间只读**,只有一个写例外:`POST /approve`
35
+ * (0.3.22,见下)。其余一律不允许出现写文件、改文档、执行命令、拉起进程的路由 ——
36
+ * `bridge.json` 里那个令牌对本机同用户进程可读,爆炸半径必须封在"泄露编辑器里的信息",
37
+ * 绝不能变成任意文件写 / 任意命令执行。
38
+ * 2. **`/approve` 的四条约束(缺一条就等于开了任意命令执行的后门,不许放宽)**:
39
+ * (a) 它只能**回答**已经存在的授权请求,不能发起任何动作 —— 请求体只有 `{id, outcome}`,
40
+ * 不接受自由文本 / 路径 / 命令参数;
41
+ * (b) `id` 必须是本进程自己发起、且**仍未决**的请求(用后即废,单次使用);
42
+ * (c) `outcome` 只接受 `allowed-once` / `rejected` 两个白名单值,没有"永久允许";
43
+ * (d) 没有面板在看 / 窗口超时(默认 8s)→ **交回官方链路**,绝不自动放行(DSH 的
44
+ * `approval/request` 本身 fail closed,这里只能把"没人答"保持成"没人答")。
45
+ * 3. **带 `Origin` 的请求一律 403。** 浏览器发起的请求必带 Origin,扩展宿主进程不带。
38
46
  * 这条只排除浏览器,不误伤扩展(也顺带挡住 CSRF / DNS rebinding 这类"用你的浏览器打本机端口")。
39
- * 3. **路径收敛在编辑器当前工作区**(由扩展侧 `workspace.getWorkspaceFolder` 判定)。
40
- * 4. **有界。** 事件缓冲 64 条、请求/响应体上限见下方常量。
47
+ * 4. **路径收敛在编辑器当前工作区**(由扩展侧 `workspace.getWorkspaceFolder` 判定)。
48
+ * 5. **有界。** 事件缓冲 64 条、请求/响应体上限见下方常量;对话流每会话 ≤120 条、单条正文 ≤8000 字符、
49
+ * 同时 watch ≤4 个会话,待决授权 ≤4 条(见 lib/bridge-thread.mjs / lib/bridge-approval.mjs)。
41
50
  */
42
51
 
43
52
  import { randomBytes, timingSafeEqual } from 'node:crypto';