@yeaft/webchat-agent 1.0.500 → 1.0.502

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,117 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { CONFIG } from './config.js';
3
+ import { userDb } from './db/user-db.js';
4
+ import { agents, previewFiles } from './context.js';
5
+ import { forwardToAgent, resolveAgentAccessError } from './ws-utils.js';
6
+ import { agentSupportsWorkbenchRequestCorrelation, currentWorkbenchWorkspaceGeneration, resolveWorkbenchRequest } from './workbench-route.js';
7
+ import { registerWorkbenchRequest, deleteWorkbenchRequest } from './workbench-correlation.js';
8
+ import { cachePreviewFile, MAX_PREVIEW_FILE_BYTES, prunePreviewFiles } from './preview-files.js';
9
+
10
+ const inflight = new Map();
11
+ const MAX_REFILLS = 16;
12
+ const key = () => createHash('sha256').update(`workbench-preview:${CONFIG.jwtSecret}`).digest();
13
+ const failure = (status, message) => Object.assign(new Error(message), { status });
14
+
15
+ /** Mint a non-expiring, encrypted capability for one authorized Agent/Session/file.
16
+ * Only bytes are cached; the URL retains its source across eviction and Server restart.
17
+ * Paths and user identifiers are encrypted, not exposed in a readable URL payload.
18
+ */
19
+ export function createWorkbenchPreview(fileId, pending, filePath) {
20
+ if (!pending?.route || !filePath) return null;
21
+ const source = {
22
+ fileId, filePath, agentId: pending.agentId, userId: pending.userId,
23
+ route: pending.route, workspaceGeneration: pending.workspaceGeneration,
24
+ };
25
+ const iv = randomBytes(12);
26
+ const cipher = createCipheriv('aes-256-gcm', key(), iv);
27
+ const encrypted = Buffer.concat([cipher.update(JSON.stringify(source)), cipher.final()]);
28
+ return `wb1.${Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64url')}`;
29
+ }
30
+
31
+ function decodeSource(fileId, token) {
32
+ try {
33
+ if (typeof token !== 'string' || !token.startsWith('wb1.') || token.length > 24000) throw new Error();
34
+ const bytes = Buffer.from(token.slice(4), 'base64url');
35
+ const cipher = createDecipheriv('aes-256-gcm', key(), bytes.subarray(0, 12));
36
+ cipher.setAuthTag(bytes.subarray(12, 28));
37
+ const source = JSON.parse(Buffer.concat([cipher.update(bytes.subarray(28)), cipher.final()]).toString());
38
+ if (source.fileId !== fileId || source.route?.agentId !== source.agentId) throw new Error();
39
+ return source;
40
+ } catch {
41
+ throw failure(403, 'Forbidden');
42
+ }
43
+ }
44
+
45
+ function authorize(source) {
46
+ const user = userDb.get(source.userId);
47
+ if (!CONFIG.skipAuth && (!user || user.deletion_state !== 'active' || !['pro', 'admin'].includes(user.role))) {
48
+ throw failure(403, 'Preview access denied');
49
+ }
50
+ const client = { userId: source.userId, role: user?.role, workbenchRouteProtocol: 1 };
51
+ const accessError = resolveAgentAccessError(source.agentId, client.userId, client.role);
52
+ if (accessError) throw failure(accessError === 'Agent access denied' ? 403 : 503, accessError);
53
+ const generation = currentWorkbenchWorkspaceGeneration({ ...client, route: source.route });
54
+ if (generation === null || (generation && generation !== source.workspaceGeneration)) {
55
+ throw failure(410, 'Preview Session or workspace is no longer available');
56
+ }
57
+ const resolved = resolveWorkbenchRequest(client, {
58
+ workbenchRoute: source.route,
59
+ workbenchWorkspaceGeneration: source.workspaceGeneration,
60
+ }, source.agentId);
61
+ if (!resolved || resolved.legacy) throw failure(410, 'Preview Session is no longer available');
62
+ return { client, resolved };
63
+ }
64
+
65
+ async function refill(source, token, { client, resolved }) {
66
+ let requestId;
67
+ const clientId = `preview:${randomUUID()}`;
68
+ const publicRequestId = randomUUID();
69
+ try {
70
+ const file = await new Promise((resolve, reject) => {
71
+ requestId = registerWorkbenchRequest({
72
+ agentId: source.agentId, clientId, publicRequestId, userId: source.userId,
73
+ allowLegacyCorrelation: !agentSupportsWorkbenchRequestCorrelation(agents.get(source.agentId)),
74
+ route: source.route, routeKey: resolved.routeKey, conversationId: resolved.conversationId,
75
+ workspaceGeneration: source.workspaceGeneration, role: client.role,
76
+ requestType: 'read_file', expectedResponseTypes: ['file_content'],
77
+ onTimeout: (_pending, reason) => reject(failure(reason === 'timeout' ? 504 : 503, 'Preview read timed out or disconnected')),
78
+ onResponse: msg => {
79
+ if (msg.error) return reject(failure(msg.errorCode === 'ENOENT' ? 404 : 502, msg.error));
80
+ if (!msg.binary || msg.filePath !== source.filePath) return reject(failure(502, 'Invalid preview response'));
81
+ const buffer = msg.buffer || Buffer.from(msg.content || '', 'base64');
82
+ if (buffer.length > MAX_PREVIEW_FILE_BYTES) return reject(failure(413, 'File exceeds the 20 MB transfer limit'));
83
+ resolve({ buffer, mimeType: msg.mimeType, filename: source.filePath.split(/[\\/]/).pop(), token });
84
+ },
85
+ });
86
+ if (!requestId) return reject(failure(503, 'Preview read unavailable'));
87
+ Promise.resolve(forwardToAgent(source.agentId, {
88
+ type: 'read_file', filePath: source.filePath, workDir: resolved.workDir,
89
+ conversationId: resolved.conversationId, workbenchRoute: source.route,
90
+ workbenchRouteKey: resolved.routeKey, workbenchWorkspaceGeneration: source.workspaceGeneration,
91
+ _workbenchRequestId: requestId, _requestUserId: source.userId,
92
+ _requestClientId: clientId, requestId: publicRequestId,
93
+ })).then(sent => {
94
+ if (sent === false) reject(failure(503, 'Agent is offline'));
95
+ }, () => reject(failure(503, 'Agent read failed')));
96
+ });
97
+ authorize(source); // Ownership/workspace may have changed while the Agent was reading.
98
+ cachePreviewFile(source.fileId, file); // Cache pressure must not invalidate a stable URL.
99
+ return file;
100
+ } finally {
101
+ if (requestId) deleteWorkbenchRequest({ agentId: source.agentId, requestId });
102
+ }
103
+ }
104
+
105
+ /** Resolve an HTTP capability independently of the browser's active Session or socket. */
106
+ export async function readWorkbenchPreview(fileId, token) {
107
+ const source = decodeSource(fileId, token);
108
+ const access = authorize(source);
109
+ prunePreviewFiles();
110
+ const cached = previewFiles.get(fileId);
111
+ if (cached?.token === token) return cached;
112
+ if (inflight.has(fileId)) return inflight.get(fileId);
113
+ if (inflight.size >= MAX_REFILLS) throw failure(503, 'Preview read capacity exceeded; retry shortly');
114
+ const promise = refill(source, token, access).finally(() => inflight.delete(fileId));
115
+ inflight.set(fileId, promise);
116
+ return promise;
117
+ }
@@ -17,6 +17,7 @@ import { handleAgentSync } from './handlers/agent-sync.js';
17
17
  import { recordPerfTraceEvent } from './perf-trace.js';
18
18
  import { clearWorkbenchCorrelationsForAgent } from './workbench-correlation.js';
19
19
  import { clearBrowserRuntimeForAgent } from './browser-runtime-routes.js';
20
+ import { clearFileContentAssembliesForAgent } from './file-content-assembly.js';
20
21
  import { handleAgentBrowser } from './handlers/agent-browser.js';
21
22
  import { markAgentHeartbeatSeen } from './heartbeat-policy.js';
22
23
 
@@ -306,6 +307,7 @@ function handleAgentDisconnect(agentId, agentName, ws) {
306
307
  // Phase 4: 清理目录缓存
307
308
  clearAgentDirCache(agentId);
308
309
  clearWorkbenchCorrelationsForAgent(agentId);
310
+ clearFileContentAssembliesForAgent(agentId);
309
311
  clearBrowserRuntimeForAgent(agentId);
310
312
  for (const pending of takeAgentSettingsRequestsForAgent(agentId)) {
311
313
  const client = webClients.get(pending.clientId);
@@ -428,6 +430,7 @@ function completeAgentRegistration(ws, agentId, agentName, workDir, sessionKey,
428
430
  // unknown field. New agents flip `serverEncryptionRequired = false`
429
431
  // and stop calling encrypt() on the send path.
430
432
  acceptPlaintext: true,
433
+ serverCapabilities: ['workbench_file_content_chunks'],
431
434
  ...(upgradeAvailable && { upgradeAvailable })
432
435
  }));
433
436
 
@@ -451,7 +454,7 @@ async function handleAgentMessage(agentId, msg, ws) {
451
454
  'proxy_ws_closed', 'proxy_ws_error', 'restart_agent_ack', 'upgrade_agent_ack',
452
455
  'directory_listing', 'folders_list', 'models_list', 'yeaft_output', 'yeaft_session_output', 'session_output', 'yeaft_asset_put',
453
456
  'yeaft_history_chunk', 'yeaft_history_outline', 'yeaft_history_search_result', 'yeaft_history_window', 'slash_commands_update', 'agent_metrics',
454
- 'file_content', 'file_saved', 'file_op_result', 'file_search_result',
457
+ 'file_content', 'file_content_chunk', 'file_saved', 'file_op_result', 'file_search_result',
455
458
  'git_status_result', 'git_diff_result', 'git_op_result',
456
459
  'terminal_created', 'terminal_output', 'terminal_closed', 'terminal_error',
457
460
  'agent_capabilities_updated', 'browser_runtime_status_result', 'browser_runtime_install_progress', 'browser_runtime_error',
@@ -1 +1 @@
1
- {"version":"1.0.500"}
1
+ {"version":"1.0.502"}