@yeaft/webchat-agent 1.0.188 → 1.0.190

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 (121) hide show
  1. package/cli.js +12 -0
  2. package/index.js +10 -2
  3. package/local-run.js +218 -0
  4. package/local-runtime/server/.env.example +54 -0
  5. package/local-runtime/server/api.js +111 -0
  6. package/local-runtime/server/auth/aad.js +235 -0
  7. package/local-runtime/server/auth/login.js +156 -0
  8. package/local-runtime/server/auth/oauth-flow.js +277 -0
  9. package/local-runtime/server/auth/password-reset.js +134 -0
  10. package/local-runtime/server/auth/providers/alipay.js +125 -0
  11. package/local-runtime/server/auth/providers/github.js +82 -0
  12. package/local-runtime/server/auth/providers/google.js +60 -0
  13. package/local-runtime/server/auth/providers/microsoft.js +71 -0
  14. package/local-runtime/server/auth/providers/types.js +57 -0
  15. package/local-runtime/server/auth/providers/wechat.js +68 -0
  16. package/local-runtime/server/auth/register.js +91 -0
  17. package/local-runtime/server/auth/session-store.js +57 -0
  18. package/local-runtime/server/auth/token.js +85 -0
  19. package/local-runtime/server/auth/totp-auth.js +133 -0
  20. package/local-runtime/server/auth/utils.js +42 -0
  21. package/local-runtime/server/auth.js +8 -0
  22. package/local-runtime/server/check-node-version.js +74 -0
  23. package/local-runtime/server/config.js +298 -0
  24. package/local-runtime/server/context.js +140 -0
  25. package/local-runtime/server/create-user.js +59 -0
  26. package/local-runtime/server/database.js +12 -0
  27. package/local-runtime/server/db/connection.js +963 -0
  28. package/local-runtime/server/db/expert-db.js +171 -0
  29. package/local-runtime/server/db/identity-db.js +92 -0
  30. package/local-runtime/server/db/invitation-db.js +38 -0
  31. package/local-runtime/server/db/message-db.js +257 -0
  32. package/local-runtime/server/db/session-db.js +118 -0
  33. package/local-runtime/server/db/user-db.js +165 -0
  34. package/local-runtime/server/db/user-stats-db.js +185 -0
  35. package/local-runtime/server/db/yeaft-session-db.js +258 -0
  36. package/local-runtime/server/email.js +96 -0
  37. package/local-runtime/server/encryption.js +105 -0
  38. package/local-runtime/server/handlers/agent-conversation.js +347 -0
  39. package/local-runtime/server/handlers/agent-file-terminal.js +99 -0
  40. package/local-runtime/server/handlers/agent-output.js +854 -0
  41. package/local-runtime/server/handlers/agent-sync.js +399 -0
  42. package/local-runtime/server/handlers/agent-work-center.js +27 -0
  43. package/local-runtime/server/handlers/client-conversation.js +1182 -0
  44. package/local-runtime/server/handlers/client-misc.js +254 -0
  45. package/local-runtime/server/handlers/client-work-center.js +269 -0
  46. package/local-runtime/server/handlers/client-workbench.js +146 -0
  47. package/local-runtime/server/handlers/session-pin-router.js +61 -0
  48. package/local-runtime/server/heartbeat-policy.js +46 -0
  49. package/local-runtime/server/index.js +275 -0
  50. package/local-runtime/server/package.json +55 -0
  51. package/local-runtime/server/perf-trace.js +154 -0
  52. package/local-runtime/server/proxy.js +273 -0
  53. package/local-runtime/server/routes/admin-routes.js +207 -0
  54. package/local-runtime/server/routes/auth-routes.js +322 -0
  55. package/local-runtime/server/routes/expert-routes.js +117 -0
  56. package/local-runtime/server/routes/invitation-routes.js +60 -0
  57. package/local-runtime/server/routes/session-routes.js +112 -0
  58. package/local-runtime/server/routes/upload-routes.js +109 -0
  59. package/local-runtime/server/routes/user-routes.js +241 -0
  60. package/local-runtime/server/totp.js +74 -0
  61. package/local-runtime/server/work-item-attachment-policy.js +56 -0
  62. package/local-runtime/server/ws-agent.js +319 -0
  63. package/local-runtime/server/ws-client.js +214 -0
  64. package/local-runtime/server/ws-utils.js +394 -0
  65. package/local-runtime/server/yeaft-asset-store.js +339 -0
  66. package/local-runtime/version.json +1 -0
  67. package/local-runtime/web/app.bundle.js +7673 -0
  68. package/local-runtime/web/app.bundle.js.gz +0 -0
  69. package/local-runtime/web/assets/avatars/README.md +34 -0
  70. package/local-runtime/web/assets/avatars/ada.svg +1 -0
  71. package/local-runtime/web/assets/avatars/alan.svg +1 -0
  72. package/local-runtime/web/assets/avatars/alice.svg +1 -0
  73. package/local-runtime/web/assets/avatars/anders.svg +1 -0
  74. package/local-runtime/web/assets/avatars/bezos.svg +1 -0
  75. package/local-runtime/web/assets/avatars/borges.svg +1 -0
  76. package/local-runtime/web/assets/avatars/buffett.svg +1 -0
  77. package/local-runtime/web/assets/avatars/clausewitz.svg +1 -0
  78. package/local-runtime/web/assets/avatars/dalio.svg +1 -0
  79. package/local-runtime/web/assets/avatars/dieter.svg +1 -0
  80. package/local-runtime/web/assets/avatars/drucker.svg +1 -0
  81. package/local-runtime/web/assets/avatars/einstein.svg +1 -0
  82. package/local-runtime/web/assets/avatars/grace.svg +1 -0
  83. package/local-runtime/web/assets/avatars/harari.svg +1 -0
  84. package/local-runtime/web/assets/avatars/jung.svg +1 -0
  85. package/local-runtime/web/assets/avatars/kahneman.svg +1 -0
  86. package/local-runtime/web/assets/avatars/ken.svg +1 -0
  87. package/local-runtime/web/assets/avatars/kongzi.svg +1 -0
  88. package/local-runtime/web/assets/avatars/kubrick.svg +1 -0
  89. package/local-runtime/web/assets/avatars/linus.svg +1 -0
  90. package/local-runtime/web/assets/avatars/luxun.svg +1 -0
  91. package/local-runtime/web/assets/avatars/margaret.svg +1 -0
  92. package/local-runtime/web/assets/avatars/martin.svg +1 -0
  93. package/local-runtime/web/assets/avatars/miyazaki.svg +1 -0
  94. package/local-runtime/web/assets/avatars/munger.svg +1 -0
  95. package/local-runtime/web/assets/avatars/nietzsche.svg +1 -0
  96. package/local-runtime/web/assets/avatars/norman.svg +1 -0
  97. package/local-runtime/web/assets/avatars/shannon.svg +1 -0
  98. package/local-runtime/web/assets/avatars/simaqian.svg +1 -0
  99. package/local-runtime/web/assets/avatars/socrates.svg +1 -0
  100. package/local-runtime/web/assets/avatars/steve.svg +1 -0
  101. package/local-runtime/web/assets/avatars/sudongpo.svg +1 -0
  102. package/local-runtime/web/assets/avatars/sunzi.svg +1 -0
  103. package/local-runtime/web/docx-preview.min.js +2 -0
  104. package/local-runtime/web/docx-preview.min.js.gz +0 -0
  105. package/local-runtime/web/html-to-image.min.js +2 -0
  106. package/local-runtime/web/html-to-image.min.js.gz +0 -0
  107. package/local-runtime/web/index.html +21 -0
  108. package/local-runtime/web/jszip.min.js +13 -0
  109. package/local-runtime/web/jszip.min.js.gz +0 -0
  110. package/local-runtime/web/mermaid.min.js +2843 -0
  111. package/local-runtime/web/mermaid.min.js.gz +0 -0
  112. package/local-runtime/web/msal-browser.min.js +69 -0
  113. package/local-runtime/web/msal-browser.min.js.gz +0 -0
  114. package/local-runtime/web/style.bundle.css +10 -0
  115. package/local-runtime/web/style.bundle.css.gz +0 -0
  116. package/local-runtime/web/vendor.bundle.js +1522 -0
  117. package/local-runtime/web/vendor.bundle.js.gz +0 -0
  118. package/local-runtime/web/xlsx.min.js +24 -0
  119. package/local-runtime/web/xlsx.min.js.gz +0 -0
  120. package/package.json +13 -3
  121. package/scripts/prepare-local-runtime.js +25 -0
@@ -0,0 +1,319 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { WebSocket } from 'ws';
3
+ import { CONFIG } from './config.js';
4
+ import { verifyAgent } from './auth.js';
5
+ import { encodeKey } from './encryption.js';
6
+ import { agents, pendingAgentConnections } from './context.js';
7
+ import {
8
+ parseMessage, broadcastAgentList, clearAgentDirCache
9
+ } from './ws-utils.js';
10
+ import { handleAgentConversation } from './handlers/agent-conversation.js';
11
+ import { handleAgentOutput } from './handlers/agent-output.js';
12
+ import { handleAgentWorkCenter } from './handlers/agent-work-center.js';
13
+ import { handleAgentFileTerminal } from './handlers/agent-file-terminal.js';
14
+ import { handleAgentSync } from './handlers/agent-sync.js';
15
+ import { recordPerfTraceEvent } from './perf-trace.js';
16
+ import { markAgentHeartbeatSeen } from './heartbeat-policy.js';
17
+
18
+ /**
19
+ * Build the internal Map key for an agent.
20
+ * Uses `${ownerId}:${agentName}` to prevent different users' same-named
21
+ * agents from colliding. Global (AGENT_SECRET) connections use `global:`.
22
+ */
23
+ function buildAgentMapKey(ownerId, agentName) {
24
+ const prefix = ownerId || 'global';
25
+ return `${prefix}:${agentName}`;
26
+ }
27
+
28
+ export function handleAgentConnection(ws, url) {
29
+ const clientAgentId = url.searchParams.get('id') || randomUUID();
30
+ const agentName = url.searchParams.get('name') || `Agent-${clientAgentId.slice(0, 8)}`;
31
+ const instanceId = url.searchParams.get('instanceId') || clientAgentId;
32
+ const workDir = url.searchParams.get('workDir') || '';
33
+
34
+ // In development mode (SKIP_AUTH), register immediately
35
+ if (CONFIG.skipAuth) {
36
+ // Dev mode: no owner isolation, use clientAgentId as-is for backward compat
37
+ const capabilities = (url.searchParams.get('capabilities') || '').split(',').filter(Boolean);
38
+ completeAgentRegistration(ws, clientAgentId, agentName, workDir, null, capabilities, null, null, null, instanceId);
39
+
40
+ ws.on('message', async (data) => {
41
+ const agent = agents.get(clientAgentId);
42
+ if (!agent) {
43
+ console.error(`[Agent] No agent found for id: ${clientAgentId}`);
44
+ return;
45
+ }
46
+ markAgentHeartbeatSeen(agent);
47
+ const msg = await parseMessage(data, agent.sessionKey);
48
+ if (msg) {
49
+ console.log(`[Agent] Received message from ${clientAgentId}: ${msg.type}`);
50
+ if (msg.perfTraceId) {
51
+ recordPerfTraceEvent({
52
+ traceId: msg.perfTraceId,
53
+ source: 'server',
54
+ phase: 'websocket.agent_received',
55
+ at: Date.now(),
56
+ agentId: clientAgentId,
57
+ sessionId: msg.sessionId || null,
58
+ vpId: msg.vpId || null,
59
+ turnId: msg.turnId || null,
60
+ threadId: msg.threadId || null,
61
+ messageType: msg.type,
62
+ bytes: data.length || 0,
63
+ });
64
+ }
65
+ handleAgentMessage(clientAgentId, msg);
66
+ } else {
67
+ console.error(`[Agent] Failed to parse message from ${clientAgentId}`);
68
+ }
69
+ });
70
+
71
+ ws.on('close', () => {
72
+ handleAgentDisconnect(clientAgentId, agentName);
73
+ });
74
+
75
+ ws.on('error', (err) => {
76
+ console.error(`Agent error (${agentName}):`, err.message);
77
+ });
78
+ return;
79
+ }
80
+
81
+ // In production mode, wait for auth message with secret
82
+ const tempId = randomUUID();
83
+ // Mutable: will be updated to the owner-scoped key after auth succeeds
84
+ let resolvedAgentId = null;
85
+
86
+ const authTimeout = setTimeout(() => {
87
+ console.log(`Agent auth timeout: ${agentName}`);
88
+ pendingAgentConnections.delete(tempId);
89
+ ws.close(1008, 'Authentication timeout');
90
+ }, 30000);
91
+
92
+ pendingAgentConnections.set(tempId, {
93
+ ws,
94
+ agentId: clientAgentId,
95
+ agentName,
96
+ instanceId,
97
+ workDir,
98
+ timeout: authTimeout
99
+ });
100
+
101
+ // Request authentication
102
+ ws.send(JSON.stringify({
103
+ type: 'auth_required',
104
+ tempId
105
+ }));
106
+
107
+ ws.on('message', async (data) => {
108
+ const pending = pendingAgentConnections.get(tempId);
109
+ if (pending) {
110
+ // Still pending authentication
111
+ try {
112
+ const msg = JSON.parse(data.toString());
113
+ if (msg.type === 'auth' && msg.tempId === tempId) {
114
+ clearTimeout(pending.timeout);
115
+ pendingAgentConnections.delete(tempId);
116
+
117
+ const authResult = verifyAgent(msg.secret);
118
+ if (!authResult.valid) {
119
+ console.log(`Agent auth failed: ${agentName}`);
120
+ ws.close(1008, 'Invalid agent secret');
121
+ return;
122
+ }
123
+
124
+ const capabilities = msg.capabilities || [];
125
+ const agentVersion = msg.version || null;
126
+ // Build owner-scoped key to prevent cross-user collision. New agents
127
+ // identify local service instances separately from display names;
128
+ // old agents keep using their name as the stable id.
129
+ resolvedAgentId = buildAgentMapKey(authResult.userId, pending.instanceId || pending.agentId || pending.agentName);
130
+ completeAgentRegistration(ws, resolvedAgentId, pending.agentName, pending.workDir, authResult.sessionKey, capabilities, authResult.userId, authResult.username, agentVersion, pending.instanceId || pending.agentId || pending.agentName);
131
+ }
132
+ } catch (e) {
133
+ console.error('Failed to parse agent auth message:', e.message);
134
+ }
135
+ } else {
136
+ // Already authenticated, handle normally
137
+ if (!resolvedAgentId) return;
138
+ const agent = agents.get(resolvedAgentId);
139
+ if (!agent) {
140
+ console.error(`[Agent] No agent found for id: ${resolvedAgentId}`);
141
+ return;
142
+ }
143
+ markAgentHeartbeatSeen(agent);
144
+ const msg = await parseMessage(data, agent.sessionKey);
145
+ if (msg) {
146
+ console.log(`[Agent] Received message from ${resolvedAgentId}: ${msg.type}`);
147
+ if (msg.perfTraceId) {
148
+ recordPerfTraceEvent({
149
+ traceId: msg.perfTraceId,
150
+ source: 'server',
151
+ phase: 'websocket.agent_received',
152
+ at: Date.now(),
153
+ agentId: resolvedAgentId,
154
+ sessionId: msg.sessionId || null,
155
+ vpId: msg.vpId || null,
156
+ turnId: msg.turnId || null,
157
+ threadId: msg.threadId || null,
158
+ messageType: msg.type,
159
+ bytes: data.length || 0,
160
+ });
161
+ }
162
+ handleAgentMessage(resolvedAgentId, msg);
163
+ } else {
164
+ console.error(`[Agent] Failed to parse message from ${resolvedAgentId}`);
165
+ }
166
+ }
167
+ });
168
+
169
+ ws.on('close', () => {
170
+ const pending = pendingAgentConnections.get(tempId);
171
+ if (pending) {
172
+ clearTimeout(pending.timeout);
173
+ pendingAgentConnections.delete(tempId);
174
+ }
175
+ // Use resolvedAgentId if auth completed, otherwise nothing to clean
176
+ if (resolvedAgentId) {
177
+ handleAgentDisconnect(resolvedAgentId, agentName);
178
+ }
179
+ });
180
+
181
+ ws.on('error', (err) => {
182
+ console.error(`Agent error (${agentName}):`, err.message);
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Shared disconnect handler: clean up and remove agent from the agents Map.
188
+ * Conversations are persisted in DB and will be restored on reconnect via
189
+ * get_agents (client-side recovery) and conversation_list (agent-side sync).
190
+ */
191
+ function handleAgentDisconnect(agentId, agentName) {
192
+ const agent = agents.get(agentId);
193
+ // Phase 4: 清理目录缓存
194
+ clearAgentDirCache(agentId);
195
+ // Phase 1: 清理同步超时
196
+ if (agent?._syncTimeout) {
197
+ clearTimeout(agent._syncTimeout);
198
+ }
199
+ // Remove agent entirely — eliminates zombie agents from broadcastAgentList
200
+ agents.delete(agentId);
201
+ console.log(`Agent disconnected: ${agentName}`);
202
+ broadcastAgentList();
203
+ }
204
+
205
+ function completeAgentRegistration(ws, agentId, agentName, workDir, sessionKey, capabilities = [], ownerId = null, ownerUsername = null, agentVersion = null, instanceId = null) {
206
+ // 如果是重连,保留 conversations;否则(server 重启)创建空 Map
207
+ const existingAgent = agents.get(agentId);
208
+ const conversations = existingAgent?.conversations || new Map();
209
+ const proxyPorts = (existingAgent?.proxyPorts || []).map(p => ({ ...p, enabled: false }));
210
+ const slashCommands = existingAgent?.slashCommands || [];
211
+ const slashCommandDescriptions = existingAgent?.slashCommandDescriptions || {};
212
+
213
+ // 兼容旧版 agent:未上报 capabilities 时默认全部开启
214
+ const effectiveCapabilities = capabilities.length > 0
215
+ ? capabilities
216
+ : ['terminal', 'file_editor', 'background_tasks'];
217
+
218
+ // feat-ws-plaintext-negotiation: new agents advertise `plaintext-ok` in
219
+ // their capability list (either via the URL query string in skipAuth
220
+ // path or in the `auth` frame in prod path). When absent, treat as old
221
+ // agent and keep encrypting outbound (back-compat).
222
+ const encryptOutbound = !effectiveCapabilities.includes('plaintext-ok');
223
+
224
+ agents.set(agentId, {
225
+ ws,
226
+ name: agentName,
227
+ instanceId,
228
+ workDir,
229
+ conversations,
230
+ sessionKey,
231
+ isAlive: true,
232
+ lastSeenAt: Date.now(),
233
+ capabilities: effectiveCapabilities,
234
+ proxyPorts,
235
+ slashCommands,
236
+ slashCommandDescriptions,
237
+ status: 'syncing',
238
+ ownerId,
239
+ ownerUsername,
240
+ version: agentVersion,
241
+ encryptOutbound
242
+ });
243
+
244
+ // 同步超时保护:30 秒后强制 ready
245
+ const syncTimeout = setTimeout(() => {
246
+ const ag = agents.get(agentId);
247
+ if (ag && ag.status === 'syncing') {
248
+ console.warn(`[Sync] Agent ${agentName} sync timeout, forcing ready`);
249
+ ag.status = 'ready';
250
+ broadcastAgentList();
251
+ }
252
+ }, 30000);
253
+ agents.get(agentId)._syncTimeout = syncTimeout;
254
+
255
+ // 心跳响应处理 + latency 测量
256
+ ws.on('pong', () => {
257
+ const agent = agents.get(agentId);
258
+ if (agent) {
259
+ markAgentHeartbeatSeen(agent);
260
+ if (agent.pingSentAt) {
261
+ agent.latency = Date.now() - agent.pingSentAt;
262
+ agent.pingSentAt = null;
263
+ }
264
+ }
265
+ });
266
+
267
+ // Send registration (with session key only in production mode)
268
+ const latestAgentVersion = process.env.AGENT_LATEST_VERSION || null;
269
+ const upgradeAvailable = (latestAgentVersion && agentVersion && latestAgentVersion !== agentVersion) ? latestAgentVersion : null;
270
+
271
+ ws.send(JSON.stringify({
272
+ type: 'registered',
273
+ agentId,
274
+ sessionKey: sessionKey ? encodeKey(sessionKey) : null,
275
+ // feat-ws-plaintext-negotiation: tell new agents that this server
276
+ // will accept plaintext outbound from them. Old agents ignore the
277
+ // unknown field. New agents flip `serverEncryptionRequired = false`
278
+ // and stop calling encrypt() on the send path.
279
+ acceptPlaintext: true,
280
+ ...(upgradeAvailable && { upgradeAvailable })
281
+ }));
282
+
283
+ console.log(`Agent connected: ${agentName} (${agentId})${encryptOutbound ? '' : ' [plaintext mode]'}`);
284
+ broadcastAgentList();
285
+ }
286
+
287
+ async function handleAgentMessage(agentId, msg) {
288
+ const agent = agents.get(agentId);
289
+ if (!agent) return;
290
+
291
+ // Security: 需要 conversationId 的消息类型,验证该 conversation 属于此 agent.
292
+ // The conversation-id check only authorizes Chat flows where the
293
+ // conversationId IS the ownership key. Workbench responses and Yeaft
294
+ // events use `_requestUserId` for ownership (enforced in
295
+ // `forwardToClients`), so they bypass this gate. See PR #772.
296
+ const CONV_EXEMPT_TYPES = new Set([
297
+ 'conversation_list', 'conversation_created', 'conversation_resumed',
298
+ 'agent_sync_complete', 'sync_sessions', 'proxy_response', 'proxy_response_chunk',
299
+ 'proxy_response_end', 'proxy_ports_update', 'proxy_ws_opened', 'proxy_ws_message',
300
+ 'proxy_ws_closed', 'proxy_ws_error', 'restart_agent_ack', 'upgrade_agent_ack',
301
+ 'directory_listing', 'folders_list', 'models_list', 'yeaft_output', 'yeaft_session_output', 'session_output', 'yeaft_asset_put',
302
+ 'yeaft_history_chunk', 'yeaft_history_search_result', 'yeaft_history_window', 'slash_commands_update', 'agent_metrics',
303
+ 'file_content', 'file_saved', 'file_op_result', 'file_search_result',
304
+ 'git_status_result', 'git_diff_result', 'git_op_result'
305
+ ]);
306
+ if (msg.conversationId && !CONV_EXEMPT_TYPES.has(msg.type)) {
307
+ if (!agent.conversations.has(msg.conversationId)) {
308
+ console.warn(`[Security] Agent ${agentId} sent ${msg.type} for unknown conversation ${msg.conversationId}, ignoring`);
309
+ return;
310
+ }
311
+ }
312
+
313
+ // Dispatch to handler sub-modules
314
+ if (await handleAgentConversation(agentId, agent, msg)) return;
315
+ if (await handleAgentWorkCenter(agentId, msg)) return;
316
+ if (await handleAgentOutput(agentId, agent, msg)) return;
317
+ if (await handleAgentFileTerminal(agentId, agent, msg)) return;
318
+ if (await handleAgentSync(agentId, agent, msg)) return;
319
+ }
@@ -0,0 +1,214 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { WebSocket } from 'ws';
3
+ import { CONFIG } from './config.js';
4
+ import { verifyToken, generateSkipAuthSession } from './auth.js';
5
+ import { encodeKey } from './encryption.js';
6
+ import { userDb } from './database.js';
7
+ import { agents, webClients, isHeartbeatMessageType, trackRequest } from './context.js';
8
+ import {
9
+ parseMessage, sendToWebClient, sendToAgent,
10
+ broadcastAgentList, resolveAgentAccessError
11
+ } from './ws-utils.js';
12
+ import { handleClientConversation } from './handlers/client-conversation.js';
13
+ import { handleClientWorkbench } from './handlers/client-workbench.js';
14
+ import { handleClientMisc } from './handlers/client-misc.js';
15
+ import { clearWorkCenterRequestsForClient, handleClientWorkCenter } from './handlers/client-work-center.js';
16
+ import { recordPerfTraceEvent } from './perf-trace.js';
17
+
18
+ export function handleWebConnection(ws, url) {
19
+ const clientId = randomUUID();
20
+ const token = url.searchParams.get('token');
21
+
22
+ let authenticated = false;
23
+ let sessionKey = null;
24
+ let username = null;
25
+ let userId = null;
26
+ let role = null;
27
+
28
+ // Check for skip auth mode
29
+ if (CONFIG.skipAuth) {
30
+ authenticated = true;
31
+ const session = generateSkipAuthSession();
32
+ sessionKey = session.sessionKey;
33
+ username = 'dev-user';
34
+ role = 'admin';
35
+ } else if (token) {
36
+ const result = verifyToken(token);
37
+ if (result.valid) {
38
+ authenticated = true;
39
+ sessionKey = result.sessionKey;
40
+ username = result.username;
41
+ role = result.role === 'admin' ? 'admin' : 'pro';
42
+ }
43
+ }
44
+
45
+ // 获取或创建用户
46
+ if (authenticated && username) {
47
+ try {
48
+ const user = userDb.getOrCreate(username);
49
+ userId = user.id;
50
+ userDb.updateLogin(userId);
51
+ } catch (e) {
52
+ console.error('Failed to get/create user:', e.message);
53
+ }
54
+ }
55
+
56
+ webClients.set(clientId, {
57
+ ws,
58
+ authenticated,
59
+ username,
60
+ userId,
61
+ role,
62
+ currentAgent: null,
63
+ currentConversation: null,
64
+ sessionKey,
65
+ isAlive: true,
66
+ // feat-ws-plaintext-negotiation: per-client flag. Defaults `true`
67
+ // (= old client, encrypt outbound for back-compat). Flipped to
68
+ // `false` when the client sends `client_hello { plaintextOk: true }`
69
+ // — see early dispatch in handleWebMessage.
70
+ encryptOutbound: true
71
+ });
72
+
73
+ // 心跳响应处理
74
+ ws.on('pong', () => {
75
+ const client = webClients.get(clientId);
76
+ if (client) client.isAlive = true;
77
+ });
78
+
79
+ console.log(`Web client connected: ${clientId} (authenticated: ${authenticated})`);
80
+
81
+ const client = webClients.get(clientId);
82
+
83
+ if (authenticated) {
84
+ // Send auth result unencrypted (client doesn't have key yet).
85
+ // `acceptPlaintext: true` advertises that this server will accept
86
+ // plaintext from a new client. Old clients ignore the unknown field.
87
+ // The corresponding flip on the server side (stop encrypting outbound
88
+ // to this client) happens when the new client confirms it can speak
89
+ // plaintext via the `client_hello` frame — see handleWebMessage.
90
+ ws.send(JSON.stringify({
91
+ type: 'auth_result',
92
+ success: true,
93
+ sessionKey: sessionKey ? encodeKey(sessionKey) : null,
94
+ role,
95
+ acceptPlaintext: true
96
+ }));
97
+ setTimeout(() => broadcastAgentList(), 100);
98
+ } else {
99
+ ws.send(JSON.stringify({ type: 'auth_result', success: false, error: 'Authentication required' }));
100
+ ws.close(1008, 'Authentication required');
101
+ return;
102
+ }
103
+
104
+ ws.on('message', async (data) => {
105
+ const client = webClients.get(clientId);
106
+ const msg = await parseMessage(data, client?.sessionKey);
107
+ if (!msg) return;
108
+ // Stats tracking: exclude heartbeat/control frames. User-turn bytes are
109
+ // accounted at the send handlers, not here, so pings and dashboard polling
110
+ // don't inflate traffic.
111
+ if (!isHeartbeatMessageType(msg.type)) {
112
+ trackRequest(client?.userId, data.length || 0, msg.type);
113
+ }
114
+ if (msg.perfTraceId) {
115
+ recordPerfTraceEvent({
116
+ traceId: msg.perfTraceId,
117
+ source: 'server',
118
+ phase: 'websocket.web_received',
119
+ at: Date.now(),
120
+ userId: client?.userId || null,
121
+ agentId: msg.agentId || client?.currentAgent || null,
122
+ sessionId: msg.sessionId || null,
123
+ messageType: msg.type,
124
+ bytes: data.length || 0,
125
+ });
126
+ }
127
+ handleWebMessage(clientId, msg);
128
+ });
129
+
130
+ ws.on('close', () => {
131
+ const client = webClients.get(clientId);
132
+ // Web 客户端断开时,检查是否需要禁用其关联 Agent 的端口
133
+ if (client?.currentAgent) {
134
+ const agentId = client.currentAgent;
135
+ const agent = agents.get(agentId);
136
+ if (agent?.proxyPorts?.length > 0) {
137
+ // 检查是否还有其他 Web 客户端连接到同一个 agent
138
+ let otherClientsOnAgent = false;
139
+ for (const [otherId, otherClient] of webClients.entries()) {
140
+ if (otherId !== clientId && otherClient.currentAgent === agentId && otherClient.ws.readyState === WebSocket.OPEN) {
141
+ otherClientsOnAgent = true;
142
+ break;
143
+ }
144
+ }
145
+ // 只有当没有其他 Web 客户端连接到同一 agent 时才禁用
146
+ if (!otherClientsOnAgent) {
147
+ agent.proxyPorts = agent.proxyPorts.map(p => ({ ...p, enabled: false }));
148
+ if (agent.ws.readyState === WebSocket.OPEN) {
149
+ sendToAgent(agent, { type: 'proxy_update_ports', ports: agent.proxyPorts });
150
+ }
151
+ broadcastAgentList();
152
+ }
153
+ }
154
+ }
155
+ clearWorkCenterRequestsForClient(client);
156
+ webClients.delete(clientId);
157
+ console.log(`Web client disconnected: ${clientId}`);
158
+ });
159
+
160
+ ws.on('error', (err) => {
161
+ console.error(`Web client error (${clientId}):`, err.message);
162
+ });
163
+ }
164
+
165
+ // Workbench 功能(terminal、file、git、proxy)仅 admin/pro 可用
166
+ const WORKBENCH_TYPES = new Set([
167
+ 'terminal_create', 'terminal_input', 'terminal_resize', 'terminal_close',
168
+ 'read_file', 'write_file', 'create_file', 'delete_files', 'move_files', 'copy_files', 'upload_to_dir', 'file_search',
169
+ 'git_status', 'git_diff', 'git_add', 'git_reset', 'git_restore', 'git_commit', 'git_push',
170
+ 'proxy_update_ports', 'update_file_tabs', 'restore_file_tabs'
171
+ ]);
172
+
173
+ async function handleWebMessage(clientId, msg) {
174
+ const client = webClients.get(clientId);
175
+ if (!client || !client.authenticated) return;
176
+
177
+ // feat-ws-plaintext-negotiation: early capability frame from new web
178
+ // clients. Tells the server "you may stop encrypting outbound to me".
179
+ // Old clients never send this; their per-client `encryptOutbound` flag
180
+ // stays `true` and we keep the ciphertext path.
181
+ if (msg.type === 'client_hello') {
182
+ if (msg.plaintextOk === true) {
183
+ client.encryptOutbound = false;
184
+ console.log(`[WS] Client ${clientId} negotiated plaintext mode`);
185
+ }
186
+ return;
187
+ }
188
+
189
+ // Workbench 权限检查:仅 admin 和 pro 可用(当前所有用户都是 pro 或 admin)
190
+ if (!CONFIG.skipAuth && WORKBENCH_TYPES.has(msg.type) && client.role !== 'admin' && client.role !== 'pro') {
191
+ console.warn(`[Security] User ${client.userId} (role=${client.role}) denied workbench action: ${msg.type}`);
192
+ await sendToWebClient(client, { type: 'error', message: 'Permission denied: workbench access requires pro or admin role' });
193
+ return;
194
+ }
195
+
196
+ // Helper: check agent access (ownership + availability)
197
+ const checkAgentAccess = async (agentId) => {
198
+ const error = resolveAgentAccessError(agentId, client.userId, client.role);
199
+ if (!error) return true;
200
+ if (error === 'Agent access denied') {
201
+ console.warn(`[Security] User ${client.userId} denied access to agent ${agentId}`);
202
+ } else {
203
+ console.warn(`[WS] Agent unavailable for user ${client.userId}: ${agentId || '(none)'}`);
204
+ }
205
+ await sendToWebClient(client, { type: 'error', message: error });
206
+ return false;
207
+ };
208
+
209
+ // Dispatch to handler sub-modules
210
+ if (await handleClientConversation(clientId, client, msg, checkAgentAccess)) return;
211
+ if (await handleClientWorkbench(clientId, client, msg, checkAgentAccess)) return;
212
+ if (await handleClientWorkCenter(client, msg, checkAgentAccess)) return;
213
+ if (await handleClientMisc(clientId, client, msg, checkAgentAccess)) return;
214
+ }