@yeaft/webchat-agent 1.0.188 → 1.0.189
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +12 -0
- package/index.js +10 -2
- package/local-run.js +218 -0
- package/local-runtime/server/.env.example +54 -0
- package/local-runtime/server/api.js +111 -0
- package/local-runtime/server/auth/aad.js +235 -0
- package/local-runtime/server/auth/login.js +156 -0
- package/local-runtime/server/auth/oauth-flow.js +277 -0
- package/local-runtime/server/auth/password-reset.js +134 -0
- package/local-runtime/server/auth/providers/alipay.js +125 -0
- package/local-runtime/server/auth/providers/github.js +82 -0
- package/local-runtime/server/auth/providers/google.js +60 -0
- package/local-runtime/server/auth/providers/microsoft.js +71 -0
- package/local-runtime/server/auth/providers/types.js +57 -0
- package/local-runtime/server/auth/providers/wechat.js +68 -0
- package/local-runtime/server/auth/register.js +91 -0
- package/local-runtime/server/auth/session-store.js +57 -0
- package/local-runtime/server/auth/token.js +85 -0
- package/local-runtime/server/auth/totp-auth.js +133 -0
- package/local-runtime/server/auth/utils.js +42 -0
- package/local-runtime/server/auth.js +8 -0
- package/local-runtime/server/check-node-version.js +74 -0
- package/local-runtime/server/config.js +298 -0
- package/local-runtime/server/context.js +140 -0
- package/local-runtime/server/create-user.js +59 -0
- package/local-runtime/server/database.js +12 -0
- package/local-runtime/server/db/connection.js +963 -0
- package/local-runtime/server/db/expert-db.js +171 -0
- package/local-runtime/server/db/identity-db.js +92 -0
- package/local-runtime/server/db/invitation-db.js +38 -0
- package/local-runtime/server/db/message-db.js +257 -0
- package/local-runtime/server/db/session-db.js +118 -0
- package/local-runtime/server/db/user-db.js +165 -0
- package/local-runtime/server/db/user-stats-db.js +185 -0
- package/local-runtime/server/db/yeaft-session-db.js +258 -0
- package/local-runtime/server/email.js +96 -0
- package/local-runtime/server/encryption.js +105 -0
- package/local-runtime/server/handlers/agent-conversation.js +347 -0
- package/local-runtime/server/handlers/agent-file-terminal.js +99 -0
- package/local-runtime/server/handlers/agent-output.js +854 -0
- package/local-runtime/server/handlers/agent-sync.js +399 -0
- package/local-runtime/server/handlers/agent-work-center.js +27 -0
- package/local-runtime/server/handlers/client-conversation.js +1182 -0
- package/local-runtime/server/handlers/client-misc.js +254 -0
- package/local-runtime/server/handlers/client-work-center.js +269 -0
- package/local-runtime/server/handlers/client-workbench.js +146 -0
- package/local-runtime/server/handlers/session-pin-router.js +61 -0
- package/local-runtime/server/heartbeat-policy.js +46 -0
- package/local-runtime/server/index.js +275 -0
- package/local-runtime/server/package.json +55 -0
- package/local-runtime/server/perf-trace.js +154 -0
- package/local-runtime/server/proxy.js +273 -0
- package/local-runtime/server/routes/admin-routes.js +207 -0
- package/local-runtime/server/routes/auth-routes.js +322 -0
- package/local-runtime/server/routes/expert-routes.js +117 -0
- package/local-runtime/server/routes/invitation-routes.js +60 -0
- package/local-runtime/server/routes/session-routes.js +112 -0
- package/local-runtime/server/routes/upload-routes.js +109 -0
- package/local-runtime/server/routes/user-routes.js +241 -0
- package/local-runtime/server/totp.js +74 -0
- package/local-runtime/server/work-item-attachment-policy.js +56 -0
- package/local-runtime/server/ws-agent.js +319 -0
- package/local-runtime/server/ws-client.js +214 -0
- package/local-runtime/server/ws-utils.js +394 -0
- package/local-runtime/server/yeaft-asset-store.js +339 -0
- package/local-runtime/version.json +1 -0
- package/local-runtime/web/app.bundle.js +7673 -0
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/assets/avatars/README.md +34 -0
- package/local-runtime/web/assets/avatars/ada.svg +1 -0
- package/local-runtime/web/assets/avatars/alan.svg +1 -0
- package/local-runtime/web/assets/avatars/alice.svg +1 -0
- package/local-runtime/web/assets/avatars/anders.svg +1 -0
- package/local-runtime/web/assets/avatars/bezos.svg +1 -0
- package/local-runtime/web/assets/avatars/borges.svg +1 -0
- package/local-runtime/web/assets/avatars/buffett.svg +1 -0
- package/local-runtime/web/assets/avatars/clausewitz.svg +1 -0
- package/local-runtime/web/assets/avatars/dalio.svg +1 -0
- package/local-runtime/web/assets/avatars/dieter.svg +1 -0
- package/local-runtime/web/assets/avatars/drucker.svg +1 -0
- package/local-runtime/web/assets/avatars/einstein.svg +1 -0
- package/local-runtime/web/assets/avatars/grace.svg +1 -0
- package/local-runtime/web/assets/avatars/harari.svg +1 -0
- package/local-runtime/web/assets/avatars/jung.svg +1 -0
- package/local-runtime/web/assets/avatars/kahneman.svg +1 -0
- package/local-runtime/web/assets/avatars/ken.svg +1 -0
- package/local-runtime/web/assets/avatars/kongzi.svg +1 -0
- package/local-runtime/web/assets/avatars/kubrick.svg +1 -0
- package/local-runtime/web/assets/avatars/linus.svg +1 -0
- package/local-runtime/web/assets/avatars/luxun.svg +1 -0
- package/local-runtime/web/assets/avatars/margaret.svg +1 -0
- package/local-runtime/web/assets/avatars/martin.svg +1 -0
- package/local-runtime/web/assets/avatars/miyazaki.svg +1 -0
- package/local-runtime/web/assets/avatars/munger.svg +1 -0
- package/local-runtime/web/assets/avatars/nietzsche.svg +1 -0
- package/local-runtime/web/assets/avatars/norman.svg +1 -0
- package/local-runtime/web/assets/avatars/shannon.svg +1 -0
- package/local-runtime/web/assets/avatars/simaqian.svg +1 -0
- package/local-runtime/web/assets/avatars/socrates.svg +1 -0
- package/local-runtime/web/assets/avatars/steve.svg +1 -0
- package/local-runtime/web/assets/avatars/sudongpo.svg +1 -0
- package/local-runtime/web/assets/avatars/sunzi.svg +1 -0
- package/local-runtime/web/docx-preview.min.js +2 -0
- package/local-runtime/web/docx-preview.min.js.gz +0 -0
- package/local-runtime/web/html-to-image.min.js +2 -0
- package/local-runtime/web/html-to-image.min.js.gz +0 -0
- package/local-runtime/web/index.html +21 -0
- package/local-runtime/web/jszip.min.js +13 -0
- package/local-runtime/web/jszip.min.js.gz +0 -0
- package/local-runtime/web/mermaid.min.js +2843 -0
- package/local-runtime/web/mermaid.min.js.gz +0 -0
- package/local-runtime/web/msal-browser.min.js +69 -0
- package/local-runtime/web/msal-browser.min.js.gz +0 -0
- package/local-runtime/web/style.bundle.css +10 -0
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/local-runtime/web/vendor.bundle.js +1522 -0
- package/local-runtime/web/vendor.bundle.js.gz +0 -0
- package/local-runtime/web/xlsx.min.js +24 -0
- package/local-runtime/web/xlsx.min.js.gz +0 -0
- package/package.json +13 -3
- 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
|
+
}
|