@yeaft/webchat-agent 1.0.416 → 1.0.417

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,290 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { CONFIG } from '../config.js';
3
+ import { agents } from '../context.js';
4
+ import { sendToAgent, sendToWebClient } from '../ws-utils.js';
5
+ import {
6
+ browserPeerMatchesClient,
7
+ browserServerIdentity,
8
+ deleteBrowserPeer,
9
+ getBrowserPeer,
10
+ getBrowserRoute,
11
+ mintBrowserIceServers,
12
+ registerBrowserCreateRequest,
13
+ registerBrowserRequest,
14
+ reserveBrowserPeer,
15
+ } from '../browser-runtime-routes.js';
16
+
17
+ const CLIENT_BROWSER_TYPES = new Set([
18
+ 'browser_session_create',
19
+ 'browser_session_get',
20
+ 'browser_session_list',
21
+ 'browser_session_close',
22
+ 'browser_peer_attach',
23
+ 'browser_peer_answer',
24
+ 'browser_peer_ice_candidate',
25
+ 'browser_peer_detach',
26
+ ]);
27
+
28
+ function clean(value, max = 512) {
29
+ return typeof value === 'string' ? value.trim().slice(0, max) : '';
30
+ }
31
+
32
+ function digest(value) {
33
+ return createHash('sha256').update(JSON.stringify(value || null)).digest('hex');
34
+ }
35
+
36
+ async function fail(client, msg, code, safeError = code) {
37
+ await sendToWebClient(client, {
38
+ type: String(msg.type || '').startsWith('browser_peer_') ? 'browser_peer_error' : 'browser_session_error',
39
+ requestId: clean(msg.requestId) || null,
40
+ browserSessionId: clean(msg.browserSessionId) || null,
41
+ peerId: clean(msg.peerId) || null,
42
+ connectionGeneration: Number(msg.connectionGeneration) || null,
43
+ code,
44
+ safeError,
45
+ });
46
+ return true;
47
+ }
48
+
49
+ function agentSupportsBrowser(agent) {
50
+ const capabilities = new Set(agent?.capabilities || []);
51
+ return capabilities.has('browser_runtime')
52
+ && capabilities.has('browser_webrtc')
53
+ && (capabilities.has('browser_capture_tab') || capabilities.has('browser_capture_cdp'));
54
+ }
55
+
56
+ function safeInitialUrl(value) {
57
+ const raw = clean(value, 4096) || 'about:blank';
58
+ if (raw === 'about:blank') return raw;
59
+ try {
60
+ const url = new URL(raw);
61
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null;
62
+ return url.href;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function canonicalCreate(msg) {
69
+ const initialUrl = safeInitialUrl(msg.options?.initialUrl);
70
+ if (!initialUrl) return null;
71
+ return {
72
+ sourceRef: msg.sourceRef && typeof msg.sourceRef === 'object' ? {
73
+ kind: clean(msg.sourceRef.kind, 32),
74
+ sessionId: clean(msg.sourceRef.sessionId),
75
+ conversationId: clean(msg.sourceRef.conversationId),
76
+ workItemId: clean(msg.sourceRef.workItemId),
77
+ } : null,
78
+ options: {
79
+ initialUrl,
80
+ viewport: {
81
+ width: Math.min(1920, Math.max(320, Math.floor(Number(msg.options?.viewport?.width) || 1280))),
82
+ height: Math.min(1080, Math.max(240, Math.floor(Number(msg.options?.viewport?.height) || 720))),
83
+ deviceScaleFactor: Math.min(2, Math.max(1, Number(msg.options?.viewport?.deviceScaleFactor) || 1)),
84
+ },
85
+ locale: clean(msg.options?.locale, 32) || 'en-US',
86
+ capturePreference: ['auto', 'tab'].includes(msg.options?.capturePreference)
87
+ ? msg.options.capturePreference : 'auto',
88
+ },
89
+ };
90
+ }
91
+
92
+ function ownerRoute(client, msg) {
93
+ const route = getBrowserRoute(msg.agentId, msg.browserSessionId);
94
+ return route && route.ownerUserId === client.userId ? route : null;
95
+ }
96
+
97
+ /** Owner-checked Web → Server → Agent Browser lifecycle and signaling relay. */
98
+ export async function handleClientBrowser(client, msg, checkAgentAccess) {
99
+ if (!CLIENT_BROWSER_TYPES.has(msg?.type)) return false;
100
+ if (!CONFIG.browserRuntime.enabled) return fail(client, msg, 'browser_runtime_disabled');
101
+ if (client.browserRuntimeProtocol !== 1) return fail(client, msg, 'browser_protocol_required');
102
+ const agentId = clean(msg.agentId);
103
+ if (!await checkAgentAccess(agentId)) return true;
104
+ const agent = agents.get(agentId);
105
+ if (!agentSupportsBrowser(agent)) return fail(client, msg, 'browser_runtime_unavailable');
106
+ const requestId = clean(msg.requestId);
107
+ const requestRequired = msg.type !== 'browser_peer_answer'
108
+ && msg.type !== 'browser_peer_ice_candidate';
109
+ if (requestRequired && !requestId) return fail(client, msg, 'browser_request_id_required');
110
+ const identity = browserServerIdentity(client);
111
+
112
+ if (msg.type === 'browser_session_create') {
113
+ const canonical = canonicalCreate(msg);
114
+ if (!canonical) return fail(client, msg, 'browser_url_invalid');
115
+ const registration = registerBrowserCreateRequest({
116
+ agentId,
117
+ client,
118
+ requestId,
119
+ digest: digest(canonical),
120
+ });
121
+ if (registration.conflict) return fail(client, msg, 'browser_request_conflict');
122
+ if (registration.capacity) return fail(client, msg, 'browser_request_capacity');
123
+ if (registration.duplicate) {
124
+ if (registration.request.response) {
125
+ await sendToWebClient(client, registration.request.response);
126
+ }
127
+ return true;
128
+ }
129
+ await sendToAgent(agent, {
130
+ type: msg.type,
131
+ agentId,
132
+ requestId: registration.request.serverRequestId,
133
+ ...canonical,
134
+ serverIdentity: identity,
135
+ });
136
+ return true;
137
+ }
138
+
139
+ if (msg.type === 'browser_session_list') {
140
+ const request = registerBrowserRequest({ agentId, client, requestId, kind: msg.type });
141
+ if (!request) return fail(client, msg, 'browser_request_conflict');
142
+ await sendToAgent(agent, {
143
+ type: msg.type,
144
+ agentId,
145
+ requestId: request.serverRequestId,
146
+ serverIdentity: identity,
147
+ });
148
+ return true;
149
+ }
150
+
151
+ if (msg.type === 'browser_peer_attach') {
152
+ if (!ownerRoute(client, msg)) return fail(client, msg, 'browser_session_not_found');
153
+ const connectionGeneration = Number(msg.connectionGeneration);
154
+ const reserved = reserveBrowserPeer({
155
+ agentId,
156
+ browserSessionId: clean(msg.browserSessionId),
157
+ client,
158
+ requestId,
159
+ connectionGeneration,
160
+ role: 'viewer',
161
+ });
162
+ if (reserved.error) return fail(client, msg, reserved.error);
163
+ const peer = reserved.peer;
164
+ if (reserved.duplicate) {
165
+ if (peer.state === 'prepared' || peer.state === 'offered' || peer.state === 'connected') {
166
+ await sendToWebClient(client, {
167
+ type: 'browser_peer_prepared',
168
+ agentId,
169
+ browserSessionId: peer.browserSessionId,
170
+ peerId: peer.peerId,
171
+ requestId: peer.requestId,
172
+ connectionGeneration: peer.connectionGeneration,
173
+ iceTransportPolicy: peer.iceTransportPolicy,
174
+ iceServers: peer.webIceServers || [],
175
+ role: peer.role,
176
+ });
177
+ if (peer.pendingOffer) await sendToWebClient(client, peer.pendingOffer);
178
+ }
179
+ return true;
180
+ }
181
+ const commonScope = {
182
+ ownerUserId: client.userId,
183
+ agentId,
184
+ browserSessionId: peer.browserSessionId,
185
+ peerId: peer.peerId,
186
+ connectionGeneration: peer.connectionGeneration,
187
+ };
188
+ try {
189
+ await sendToAgent(agent, {
190
+ type: 'browser_peer_prepare',
191
+ agentId,
192
+ browserSessionId: peer.browserSessionId,
193
+ peerId: peer.peerId,
194
+ requestId,
195
+ connectionGeneration: peer.connectionGeneration,
196
+ serverIdentity: identity,
197
+ routeExpiresAt: peer.expiresAt,
198
+ iceTransportPolicy: CONFIG.browserRuntime.iceTransportPolicy,
199
+ agentIceServers: mintBrowserIceServers({ ...commonScope, endpointRole: 'agent' }),
200
+ });
201
+ peer.webIceServers = mintBrowserIceServers({ ...commonScope, endpointRole: 'web' });
202
+ peer.state = 'preparing';
203
+ } catch (error) {
204
+ deleteBrowserPeer(peer.peerId);
205
+ return fail(client, msg, 'browser_peer_prepare_failed', String(error?.message || error).slice(0, 500));
206
+ }
207
+ return true;
208
+ }
209
+
210
+ if (msg.type === 'browser_session_get' || msg.type === 'browser_session_close') {
211
+ const route = ownerRoute(client, msg);
212
+ if (!route) return fail(client, msg, 'browser_session_not_found');
213
+ const request = registerBrowserRequest({
214
+ agentId,
215
+ client,
216
+ requestId,
217
+ kind: msg.type,
218
+ browserSessionId: route.browserSessionId,
219
+ });
220
+ if (!request) return fail(client, msg, 'browser_request_conflict');
221
+ await sendToAgent(agent, {
222
+ type: msg.type,
223
+ agentId,
224
+ requestId: request.serverRequestId,
225
+ browserSessionId: route.browserSessionId,
226
+ ...(msg.type === 'browser_session_close' ? { expectedRevision: Number(msg.expectedRevision) || route.revision } : {}),
227
+ serverIdentity: identity,
228
+ });
229
+ return true;
230
+ }
231
+
232
+ const peer = getBrowserPeer(msg.peerId);
233
+ if (!browserPeerMatchesClient(peer, client, msg)) return fail(client, msg, 'browser_peer_stale');
234
+ if (msg.type === 'browser_peer_answer') {
235
+ const description = msg.description;
236
+ if (description?.type !== 'answer' || typeof description.sdp !== 'string' || description.sdp.length > 96 * 1024) {
237
+ return fail(client, msg, 'browser_sdp_invalid');
238
+ }
239
+ peer.state = 'answering';
240
+ await sendToAgent(agent, {
241
+ type: msg.type,
242
+ agentId,
243
+ browserSessionId: peer.browserSessionId,
244
+ peerId: peer.peerId,
245
+ connectionGeneration: peer.connectionGeneration,
246
+ description: { type: 'answer', sdp: description.sdp },
247
+ serverIdentity: identity,
248
+ });
249
+ return true;
250
+ }
251
+ if (msg.type === 'browser_peer_ice_candidate') {
252
+ if (peer.webCandidateCount >= 128) return fail(client, msg, 'browser_candidate_limit');
253
+ const candidate = msg.candidate;
254
+ if (candidate != null && (typeof candidate !== 'object'
255
+ || typeof candidate.candidate !== 'string'
256
+ || candidate.candidate.length > 4096)) return fail(client, msg, 'browser_candidate_invalid');
257
+ peer.webCandidateCount += 1;
258
+ await sendToAgent(agent, {
259
+ type: msg.type,
260
+ agentId,
261
+ browserSessionId: peer.browserSessionId,
262
+ peerId: peer.peerId,
263
+ connectionGeneration: peer.connectionGeneration,
264
+ candidate: candidate == null ? null : {
265
+ candidate: candidate.candidate,
266
+ sdpMid: clean(candidate.sdpMid, 256) || null,
267
+ sdpMLineIndex: Number.isInteger(candidate.sdpMLineIndex) ? candidate.sdpMLineIndex : null,
268
+ usernameFragment: clean(candidate.usernameFragment, 256) || null,
269
+ },
270
+ serverIdentity: identity,
271
+ });
272
+ return true;
273
+ }
274
+ if (msg.type === 'browser_peer_detach') {
275
+ deleteBrowserPeer(peer.peerId);
276
+ await sendToAgent(agent, {
277
+ type: msg.type,
278
+ agentId,
279
+ requestId,
280
+ browserSessionId: peer.browserSessionId,
281
+ peerId: peer.peerId,
282
+ connectionGeneration: peer.connectionGeneration,
283
+ serverIdentity: identity,
284
+ });
285
+ return true;
286
+ }
287
+ return false;
288
+ }
289
+
290
+ export { CLIENT_BROWSER_TYPES };
@@ -16,6 +16,8 @@ import { handleAgentFileTerminal } from './handlers/agent-file-terminal.js';
16
16
  import { handleAgentSync } from './handlers/agent-sync.js';
17
17
  import { recordPerfTraceEvent } from './perf-trace.js';
18
18
  import { clearWorkbenchCorrelationsForAgent } from './workbench-correlation.js';
19
+ import { clearBrowserRuntimeForAgent } from './browser-runtime-routes.js';
20
+ import { handleAgentBrowser } from './handlers/agent-browser.js';
19
21
  import { markAgentHeartbeatSeen } from './heartbeat-policy.js';
20
22
 
21
23
  /**
@@ -229,6 +231,7 @@ function handleAgentDisconnect(agentId, agentName, ws) {
229
231
  // Phase 4: 清理目录缓存
230
232
  clearAgentDirCache(agentId);
231
233
  clearWorkbenchCorrelationsForAgent(agentId);
234
+ clearBrowserRuntimeForAgent(agentId);
232
235
  // Phase 1: 清理同步超时
233
236
  if (agent._syncTimeout) {
234
237
  clearTimeout(agent._syncTimeout);
@@ -294,6 +297,7 @@ function completeAgentRegistration(ws, agentId, agentName, workDir, sessionKey,
294
297
  agents.get(agentId)._syncTimeout = syncTimeout;
295
298
 
296
299
  if (existingAgent?.ws && existingAgent.ws !== ws) {
300
+ clearBrowserRuntimeForAgent(agentId);
297
301
  existingAgent.ws.close(1008, 'Superseded by a newer Agent connection');
298
302
  }
299
303
 
@@ -347,7 +351,9 @@ async function handleAgentMessage(agentId, msg, ws) {
347
351
  'yeaft_history_chunk', 'yeaft_history_outline', 'yeaft_history_search_result', 'yeaft_history_window', 'slash_commands_update', 'agent_metrics',
348
352
  'file_content', 'file_saved', 'file_op_result', 'file_search_result',
349
353
  'git_status_result', 'git_diff_result', 'git_op_result',
350
- 'terminal_created', 'terminal_output', 'terminal_closed', 'terminal_error'
354
+ 'terminal_created', 'terminal_output', 'terminal_closed', 'terminal_error',
355
+ 'browser_session_created', 'browser_session_error', 'browser_session_snapshot', 'browser_session_list_result',
356
+ 'browser_peer_prepared', 'browser_peer_offer', 'browser_peer_ice_candidate', 'browser_peer_state', 'browser_peer_error'
351
357
  ]);
352
358
  if (msg.conversationId && !CONV_EXEMPT_TYPES.has(msg.type)) {
353
359
  if (!agent.conversations.has(msg.conversationId)) {
@@ -358,6 +364,7 @@ async function handleAgentMessage(agentId, msg, ws) {
358
364
 
359
365
  // Dispatch to handler sub-modules
360
366
  if (await handleAgentConversation(agentId, agent, msg)) return;
367
+ if (await handleAgentBrowser(agentId, agent, msg)) return;
361
368
  if (await handleAgentWorkCenter(agentId, msg)) return;
362
369
  if (await handleAgentOutput(agentId, agent, msg)) return;
363
370
  if (await handleAgentFileTerminal(agentId, agent, msg)) return;
@@ -6,14 +6,16 @@ import { authenticateRequest } from './auth/request-auth.js';
6
6
  import { encodeKey } from './encryption.js';
7
7
  import { userDb } from './database.js';
8
8
  import { agents, clearYeaftDebugRequestsForClient, webClients, isHeartbeatMessageType, trackRequest } from './context.js';
9
- import { applyClientHello, WORKBENCH_ROUTE_PROTOCOL } from './client-protocol.js';
9
+ import { applyClientHello, BROWSER_RUNTIME_PROTOCOL, WORKBENCH_ROUTE_PROTOCOL } from './client-protocol.js';
10
10
  import { clearWorkbenchCorrelationsForClient } from './workbench-correlation.js';
11
+ import { clearBrowserRuntimeForClient } from './browser-runtime-routes.js';
11
12
  import {
12
13
  parseMessage, sendToWebClient, sendToAgent,
13
14
  broadcastAgentList, resolveAgentAccessError
14
15
  } from './ws-utils.js';
15
16
  import { handleClientConversation } from './handlers/client-conversation.js';
16
17
  import { handleClientWorkbench } from './handlers/client-workbench.js';
18
+ import { CLIENT_BROWSER_TYPES, handleClientBrowser } from './handlers/client-browser.js';
17
19
  import { handleClientMisc } from './handlers/client-misc.js';
18
20
  import { clearWorkCenterRequestsForClient, handleClientWorkCenter } from './handlers/client-work-center.js';
19
21
  import { recordPerfTraceEvent } from './perf-trace.js';
@@ -64,7 +66,11 @@ export function handleWebConnection(ws, url, req = {}) {
64
66
  }
65
67
  }
66
68
 
69
+ const connectionId = randomUUID();
67
70
  webClients.set(clientId, {
71
+ id: clientId,
72
+ connectionId,
73
+ connectionGeneration: connectionId,
68
74
  ws,
69
75
  authenticated,
70
76
  username,
@@ -79,8 +85,9 @@ export function handleWebConnection(ws, url, req = {}) {
79
85
  // `false` when the client sends `client_hello { plaintextOk: true }`
80
86
  // — see early dispatch in handleWebMessage.
81
87
  encryptOutbound: true,
82
- // Explicit Workbench protocol negotiation. Zero means legacy Web.
88
+ // Explicit protocols have no omission-based downgrade for security fields.
83
89
  workbenchRouteProtocol: 0,
90
+ browserRuntimeProtocol: 0,
84
91
  });
85
92
 
86
93
  // 心跳响应处理
@@ -108,6 +115,8 @@ export function handleWebConnection(ws, url, req = {}) {
108
115
  acceptPlaintext: true,
109
116
  yeaftSessionInventoryComplete: true,
110
117
  workbenchRouteProtocol: WORKBENCH_ROUTE_PROTOCOL,
118
+ browserRuntimeProtocol: BROWSER_RUNTIME_PROTOCOL,
119
+ browserRuntimeEnabled: CONFIG.browserRuntime.enabled,
111
120
  }));
112
121
  setTimeout(() => broadcastAgentList(), 100);
113
122
  } else {
@@ -169,6 +178,24 @@ export function handleWebConnection(ws, url, req = {}) {
169
178
  }
170
179
  clearWorkCenterRequestsForClient(client);
171
180
  clearYeaftDebugRequestsForClient(clientId);
181
+ const browserPeers = clearBrowserRuntimeForClient(client);
182
+ for (const peer of browserPeers) {
183
+ const agent = agents.get(peer.agentId);
184
+ if (!agent) continue;
185
+ void sendToAgent(agent, {
186
+ type: 'browser_peer_detach',
187
+ agentId: peer.agentId,
188
+ browserSessionId: peer.browserSessionId,
189
+ peerId: peer.peerId,
190
+ connectionGeneration: peer.connectionGeneration,
191
+ serverIdentity: {
192
+ ownerUserId: peer.ownerUserId,
193
+ clientId: peer.clientId,
194
+ webConnectionId: peer.webConnectionId,
195
+ webConnectionGeneration: peer.webConnectionGeneration,
196
+ },
197
+ }).catch(error => console.warn('[BrowserRuntime] peer disconnect cleanup failed:', error.message));
198
+ }
172
199
  const ownedTerminals = clearWorkbenchCorrelationsForClient(clientId);
173
200
  for (const owner of ownedTerminals) {
174
201
  const agent = agents.get(owner.agentId);
@@ -192,6 +219,7 @@ export function handleWebConnection(ws, url, req = {}) {
192
219
 
193
220
  // Workbench 功能(terminal、file、git、proxy)仅 admin/pro 可用
194
221
  const WORKBENCH_TYPES = new Set([
222
+ ...CLIENT_BROWSER_TYPES,
195
223
  'terminal_create', 'terminal_input', 'terminal_resize', 'terminal_close',
196
224
  'read_file', 'write_file', 'create_file', 'delete_files', 'move_files', 'copy_files', 'upload_to_dir', 'file_search',
197
225
  'git_status', 'git_diff', 'git_add', 'git_reset', 'git_restore', 'git_commit', 'git_push',
@@ -220,6 +248,8 @@ async function handleWebMessage(clientId, msg) {
220
248
  await sendToWebClient(client, {
221
249
  type: 'client_hello_ack',
222
250
  workbenchRouteProtocol: client.workbenchRouteProtocol,
251
+ browserRuntimeProtocol: client.browserRuntimeProtocol,
252
+ browserRuntimeEnabled: CONFIG.browserRuntime.enabled,
223
253
  });
224
254
  return;
225
255
  }
@@ -246,6 +276,7 @@ async function handleWebMessage(clientId, msg) {
246
276
 
247
277
  // Dispatch to handler sub-modules
248
278
  if (await handleClientConversation(clientId, client, msg, checkAgentAccess)) return;
279
+ if (await handleClientBrowser(client, msg, checkAgentAccess)) return;
249
280
  if (await handleClientWorkbench(clientId, client, msg, checkAgentAccess)) return;
250
281
  if (await handleClientWorkCenter(client, msg, checkAgentAccess)) return;
251
282
  if (await handleClientMisc(clientId, client, msg, checkAgentAccess)) return;
@@ -1 +1 @@
1
- {"version":"1.0.416"}
1
+ {"version":"1.0.417"}