@yeaft/webchat-agent 1.0.550 → 1.0.551

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/index.js CHANGED
@@ -180,7 +180,7 @@ async function detectCapabilities() {
180
180
  const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'workbench_session_routes', 'workbench_request_correlation', 'workbench_terminal_cleanup_fence', 'workbench_file_content_chunks', 'workbench_video_stream', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch', 'file_reference_resolution', 'response_image_preview', 'yeaft_plugins', 'yeaft_managed_skills', 'settings_request_correlation', 'work_center_feature_settings'];
181
181
  capabilities.push(getAgentUpgradeCapability());
182
182
  if (ctx.CONFIG?.workCenterEnabled === true) {
183
- capabilities.push('work_center', 'work_center_message_v2');
183
+ capabilities.push('work_center', 'work_center_message_v2', 'work_center_workbench');
184
184
  if (process.platform === 'linux') capabilities.push('work_item_attachments');
185
185
  }
186
186
  const pty = await loadNodePty();
@@ -1,4 +1,5 @@
1
1
  export const WORKBENCH_ROUTE_PROTOCOL = 1;
2
+ export const WORK_CENTER_WORKBENCH_PROTOCOL = 1;
2
3
  export const BROWSER_RUNTIME_PROTOCOL = 1;
3
4
  export const BROWSER_RUNTIME_SETUP_PROTOCOL = 1;
4
5
 
@@ -12,6 +13,9 @@ export function applyClientHello(client, message) {
12
13
  if (message.workbenchRouteProtocol === WORKBENCH_ROUTE_PROTOCOL) {
13
14
  client.workbenchRouteProtocol = WORKBENCH_ROUTE_PROTOCOL;
14
15
  }
16
+ if (message.workCenterWorkbenchProtocol === WORK_CENTER_WORKBENCH_PROTOCOL) {
17
+ client.workCenterWorkbenchProtocol = WORK_CENTER_WORKBENCH_PROTOCOL;
18
+ }
15
19
  if (message.browserRuntimeProtocol === BROWSER_RUNTIME_PROTOCOL) {
16
20
  client.browserRuntimeProtocol = BROWSER_RUNTIME_PROTOCOL;
17
21
  }
@@ -1,3 +1,4 @@
1
+ import { updateWorkItemWorkspaces } from '../work-center-workspace-cache.js';
1
2
  import { forwardAgentEvent } from '../ws-utils.js';
2
3
  import { deliverWorkCenterResponse } from './client-work-center.js';
3
4
 
@@ -21,6 +22,7 @@ export async function handleAgentWorkCenter(agentId, msg) {
21
22
  }
22
23
 
23
24
  const { agentId: _untrustedAgentId, _requestUserId, ...payload } = msg;
25
+ updateWorkItemWorkspaces(agentId, payload.event);
24
26
  const outgoing = { ...payload, agentId };
25
27
  await forwardAgentEvent(agentId, outgoing);
26
28
  return true;
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { CONFIG } from '../config.js';
3
3
  import { agents, pendingFiles, previewFiles } from '../context.js';
4
4
  import { forwardToAgent, sendToWebClient } from '../ws-utils.js';
5
+ import { forgetWorkItemWorkspace, rememberWorkItemWorkspace } from '../work-center-workspace-cache.js';
5
6
  import {
6
7
  assertSupportedWorkItemAttachment,
7
8
  assertWorkItemAttachmentSize,
@@ -204,6 +205,7 @@ export async function handleClientWorkCenter(client, msg, checkAgentAccess) {
204
205
  agentId,
205
206
  clientRequestId: typeof msg.requestId === 'string' ? msg.requestId : null,
206
207
  attachmentFileIds: resolved.consumedIds,
208
+ workItemId: typeof resolved.payload?.id === 'string' ? resolved.payload.id : null,
207
209
  expiresAt: Date.now() + REQUEST_TIMEOUT_MS,
208
210
  });
209
211
 
@@ -233,6 +235,11 @@ export async function deliverWorkCenterResponse(agentId, msg) {
233
235
  // able to resolve the original fileId and reach the Agent receipt preflight.
234
236
  const { agentId: _untrustedAgentId, requestId: _opaqueRequestId, _requestUserId, ...payload } = msg;
235
237
  let response = payload;
238
+ if (msg.ok === true && msg.op === 'delete') {
239
+ forgetWorkItemWorkspace(pending.client?.userId, agentId, pending.workItemId);
240
+ } else if (msg.ok === true && msg.data?.id) {
241
+ rememberWorkItemWorkspace(pending.client?.userId, agentId, msg.data);
242
+ }
236
243
  if (msg.ok === true && msg.op === 'preview_attachment') {
237
244
  try {
238
245
  const data = payload.data;
@@ -7,6 +7,7 @@ import {
7
7
  import {
8
8
  agentSupportsWorkbenchRequestCorrelation,
9
9
  resolveWorkbenchRequest,
10
+ workbenchPathWithinWorkspace,
10
11
  } from '../workbench-route.js';
11
12
  import {
12
13
  deleteWorkbenchRequest,
@@ -157,6 +158,35 @@ function workbenchFailureResponse({ agentId, msg, resolved, error }) {
157
158
  };
158
159
  }
159
160
 
161
+ const WORKBENCH_PATH_FIELDS = Object.freeze({
162
+ read_file: ['filePath'],
163
+ video_metadata: ['filePath'],
164
+ write_file: ['filePath'],
165
+ list_directory: ['dirPath'],
166
+ git_diff: ['filePath'],
167
+ git_add: ['filePath'],
168
+ git_reset: ['filePath'],
169
+ git_restore: ['filePath'],
170
+ file_search: ['dirPath'],
171
+ create_file: ['filePath'],
172
+ delete_files: ['paths'],
173
+ move_files: ['paths', 'destination'],
174
+ copy_files: ['paths', 'destination'],
175
+ upload_to_dir: ['dirPath'],
176
+ });
177
+
178
+ function workCenterPathsAreConfined(msg, resolved) {
179
+ if (resolved.route?.runtimeProvider !== 'work-center') return true;
180
+ for (const field of WORKBENCH_PATH_FIELDS[msg.type] || []) {
181
+ const values = Array.isArray(msg[field]) ? msg[field] : [msg[field]];
182
+ for (const value of values) {
183
+ if (value == null || value === '') continue;
184
+ if (!workbenchPathWithinWorkspace(value, resolved.workDir)) return false;
185
+ }
186
+ }
187
+ return true;
188
+ }
189
+
160
190
  function canonicalWorkbenchMessage(msg, resolved, { canonicalWorkDir = false } = {}) {
161
191
  const {
162
192
  _requestUserId: _ignoredUserId,
@@ -168,6 +198,7 @@ function canonicalWorkbenchMessage(msg, resolved, { canonicalWorkDir = false } =
168
198
  ...clientFields,
169
199
  ...(resolved.conversationId ? { conversationId: resolved.conversationId } : {}),
170
200
  };
201
+ if (!workCenterPathsAreConfined(clientFields, resolved)) return null;
171
202
  return {
172
203
  ...clientFields,
173
204
  agentId: resolved.agentId,
@@ -233,6 +264,10 @@ function correlateWorkbenchRequest({ agentId, clientId, client, msg, resolved, c
233
264
  }
234
265
 
235
266
  async function forwardCorrelatedWorkbenchRequest({ agentId, clientId, client, msg, resolved, canonical }) {
267
+ if (!canonical) {
268
+ await denyWorkbenchRoute(client, msg);
269
+ return true;
270
+ }
236
271
  const outbound = correlateWorkbenchRequest({ agentId, clientId, client, msg, resolved, canonical });
237
272
  if (!outbound) return false;
238
273
  try {
@@ -465,7 +500,7 @@ export async function handleClientWorkbench(clientId, client, msg, checkAgentAcc
465
500
  client,
466
501
  msg,
467
502
  resolved,
468
- canonical: { ...canonical, type: 'list_directory' },
503
+ canonical: canonical ? { ...canonical, type: 'list_directory' } : null,
469
504
  });
470
505
  break;
471
506
  }
@@ -0,0 +1,63 @@
1
+ import { agents } from './context.js';
2
+
3
+ // Ephemeral routing metadata, never a second WorkItem catalog. A fresh detail
4
+ // request repopulates it after reconnect/restart; old Agent connections expire.
5
+ const workspaces = new Map();
6
+ const MAX_WORKSPACES = 1000;
7
+
8
+ function clean(value, maxLength = 4096) {
9
+ if (typeof value !== 'string') return '';
10
+ const result = value.trim();
11
+ return result && result.length <= maxLength ? result : '';
12
+ }
13
+
14
+ function key(userId, agentId, workItemId) {
15
+ const parts = [userId, agentId, workItemId].map(value => clean(value, 300));
16
+ return parts.every(Boolean) ? JSON.stringify(parts) : '';
17
+ }
18
+
19
+ /** Remember Agent-projected workspace metadata only after an owner-scoped request. */
20
+ export function rememberWorkItemWorkspace(userId, agentId, detail) {
21
+ const cacheKey = key(userId, agentId, detail?.id);
22
+ const workDir = clean(detail?.workbench?.workDir);
23
+ if (!cacheKey) return false;
24
+ workspaces.delete(cacheKey);
25
+ if (!workDir || !agents.has(agentId)) return false;
26
+ workspaces.set(cacheKey, { id: detail.id, agentId, agent: agents.get(agentId), workDir, isArchived: false });
27
+ while (workspaces.size > MAX_WORKSPACES) workspaces.delete(workspaces.keys().next().value);
28
+ return true;
29
+ }
30
+
31
+ export function getWorkItemWorkspace(userId, agentId, workItemId) {
32
+ const cacheKey = key(userId, agentId, workItemId);
33
+ const row = workspaces.get(cacheKey);
34
+ if (!row || row.agent !== agents.get(agentId)) {
35
+ workspaces.delete(cacheKey);
36
+ return null;
37
+ }
38
+ return row;
39
+ }
40
+
41
+ export function forgetWorkItemWorkspace(userId, agentId, workItemId) {
42
+ workspaces.delete(key(userId, agentId, workItemId));
43
+ }
44
+
45
+ /** Events may refresh existing owner grants, never create grants for new users. */
46
+ export function updateWorkItemWorkspaces(agentId, event) {
47
+ const detail = event?.workItem;
48
+ if (!detail?.id) return;
49
+ for (const [cacheKey, row] of workspaces) {
50
+ if (row.agentId !== agentId || row.id !== detail.id) continue;
51
+ if (event.type === 'work_item.deleted' || row.agent !== agents.get(agentId)) {
52
+ workspaces.delete(cacheKey);
53
+ } else if (Object.hasOwn(detail, 'workbench')) {
54
+ const workDir = clean(detail.workbench?.workDir);
55
+ if (!workDir) workspaces.delete(cacheKey);
56
+ else row.workDir = workDir;
57
+ }
58
+ }
59
+ }
60
+
61
+ export function __testResetWorkItemWorkspaces() {
62
+ workspaces.clear();
63
+ }
@@ -65,7 +65,7 @@ function authorize(source) {
65
65
  if (!CONFIG.skipAuth && (!user || user.deletion_state !== 'active' || !['pro', 'admin'].includes(user.role))) {
66
66
  throw failure(403, 'Preview access denied');
67
67
  }
68
- const client = { userId: source.userId, role: user?.role, workbenchRouteProtocol: 1 };
68
+ const client = { userId: source.userId, role: user?.role, workbenchRouteProtocol: 1, workCenterWorkbenchProtocol: 1 };
69
69
  const accessError = resolveAgentAccessError(source.agentId, client.userId, client.role);
70
70
  if (accessError) throw failure(accessError === 'Agent access denied' ? 403 : 503, accessError);
71
71
  const generation = currentWorkbenchWorkspaceGeneration({ ...client, route: source.route });
@@ -1,11 +1,14 @@
1
+ import { posix, win32 } from 'node:path';
1
2
  import { CONFIG } from './config.js';
2
3
  import { agents } from './context.js';
3
4
  import { sessionDb } from './db/session-db.js';
4
5
  import { yeaftSessionDb } from './db/yeaft-session-db.js';
6
+ import { getWorkItemWorkspace } from './work-center-workspace-cache.js';
5
7
 
6
8
  export const WORKBENCH_SESSION_ROUTE_CAPABILITY = 'workbench_session_routes';
7
9
  export const WORKBENCH_REQUEST_CORRELATION_CAPABILITY = 'workbench_request_correlation';
8
10
  export const WORKBENCH_TERMINAL_CLEANUP_FENCE_CAPABILITY = 'workbench_terminal_cleanup_fence';
11
+ export const WORK_CENTER_WORKBENCH_CAPABILITY = 'work_center_workbench';
9
12
 
10
13
  function agentHasCapability(agent, capability) {
11
14
  return Array.isArray(agent?.capabilities) && agent.capabilities.includes(capability);
@@ -19,7 +22,7 @@ export function agentSupportsWorkbenchTerminalCleanupFence(agent) {
19
22
  return agentHasCapability(agent, WORKBENCH_TERMINAL_CLEANUP_FENCE_CAPABILITY);
20
23
  }
21
24
 
22
- const PROVIDERS = new Set(['yeaft', 'claude-code', 'copilot']);
25
+ const PROVIDERS = new Set(['yeaft', 'claude-code', 'copilot', 'work-center']);
23
26
  const SCOPES = new Set(['main', 'files-folder-picker', 'git-folder-picker']);
24
27
 
25
28
  function clean(value, maxLength = 300) {
@@ -31,9 +34,11 @@ function clean(value, maxLength = 300) {
31
34
  export function workbenchRouteKey(route) {
32
35
  const runtimeProvider = clean(route?.runtimeProvider, 32);
33
36
  const agentId = clean(route?.agentId);
34
- const sessionId = clean(route?.sessionId);
35
- if (!PROVIDERS.has(runtimeProvider) || !agentId || !sessionId) return '';
36
- return [runtimeProvider, agentId, sessionId]
37
+ const ownerId = runtimeProvider === 'work-center'
38
+ ? clean(route?.workItemId)
39
+ : clean(route?.sessionId);
40
+ if (!PROVIDERS.has(runtimeProvider) || !agentId || !ownerId) return '';
41
+ return [runtimeProvider, agentId, ownerId]
37
42
  .map(part => encodeURIComponent(part))
38
43
  .join(':');
39
44
  }
@@ -73,7 +78,9 @@ export function workbenchRouteKeyFromConversationId(conversationId, expectedAgen
73
78
  const decodedRoute = {
74
79
  runtimeProvider: decodeURIComponent(parts[0]),
75
80
  agentId: decodeURIComponent(parts[1]),
76
- sessionId: decodeURIComponent(parts[2]),
81
+ ...(decodeURIComponent(parts[0]) === 'work-center'
82
+ ? { workItemId: decodeURIComponent(parts[2]) }
83
+ : { sessionId: decodeURIComponent(parts[2]) }),
77
84
  };
78
85
  if (expectedAgentId && decodedRoute.agentId !== expectedAgentId) return '';
79
86
  return workbenchRouteKey(decodedRoute) === routeKey ? routeKey : '';
@@ -102,6 +109,7 @@ function resolveYeaftRow(client, route) {
102
109
  // never the browser's cwd or the Server process cwd.
103
110
  function resolveSessionWorkDir(row, route) {
104
111
  if (!row) return '';
112
+ if (route.runtimeProvider === 'work-center') return clean(row.workDir, 4096);
105
113
  return clean(route.runtimeProvider === 'yeaft' ? row.workDir : row.work_dir, 4096)
106
114
  || clean(agents.get(route.agentId)?.workDir, 4096);
107
115
  }
@@ -122,9 +130,40 @@ function resolveChatRow(client, route) {
122
130
  return row;
123
131
  }
124
132
 
133
+ function resolveWorkCenterRoute(client, agent, route) {
134
+ if (client?.workCenterWorkbenchProtocol !== 1
135
+ || !agentHasCapability(agent, WORK_CENTER_WORKBENCH_CAPABILITY)
136
+ || !agentSupportsWorkbenchRequestCorrelation(agent)
137
+ || !agentSupportsWorkbenchTerminalCleanupFence(agent)) return null;
138
+ const row = getWorkItemWorkspace(client?.userId, route.agentId, route.workItemId);
139
+ return row?.workDir ? row : null;
140
+ }
141
+
142
+ export function workbenchPathWithinWorkspace(filePath, workDir) {
143
+ const path = clean(filePath, 4096);
144
+ const root = clean(workDir, 4096);
145
+ if (!path || !root) return false;
146
+ // The Agent can run a different OS from the Server. Never use Server cwd.
147
+ const paths = /^(?:[a-z]:[\\/]|\\\\)/i.test(root) ? win32 : posix;
148
+ if (!paths.isAbsolute(root)) return false;
149
+ const resolvedRoot = paths.resolve(root);
150
+ const candidate = paths.resolve(resolvedRoot, path);
151
+ const relativePath = paths.relative(resolvedRoot, candidate);
152
+ return relativePath !== '..'
153
+ && !relativePath.startsWith(`..${paths.sep}`)
154
+ && !paths.isAbsolute(relativePath);
155
+ }
156
+
157
+ function resolveRouteRow(client, route, agent) {
158
+ if (route.runtimeProvider === 'work-center') return resolveWorkCenterRoute(client, agent, route);
159
+ return route.runtimeProvider === 'yeaft'
160
+ ? resolveYeaftRow(client, route)
161
+ : resolveChatRow(client, route);
162
+ }
163
+
125
164
  /**
126
165
  * Validate a browser-provided Workbench route against Server-owned Session
127
- * metadata and return canonical execution fields. Browser cwd and synthetic
166
+ * metadata or owner-scoped, Agent-projected WorkItem workspace metadata and return canonical execution fields. Browser cwd and synthetic
128
167
  * conversation ids are never authoritative.
129
168
  *
130
169
  * `legacy: true` preserves old clients that predate route-scoped Workbench.
@@ -135,10 +174,9 @@ function resolveChatRow(client, route) {
135
174
  */
136
175
  export function currentWorkbenchWorkspaceGeneration({ route, userId, role }) {
137
176
  if (!route || !userId) return null;
138
- const client = { userId, role };
139
- const row = route.runtimeProvider === 'yeaft'
140
- ? resolveYeaftRow(client, route)
141
- : resolveChatRow(client, route);
177
+ const client = { userId, role, workCenterWorkbenchProtocol: 1 };
178
+ const agent = agents.get(route.agentId);
179
+ const row = resolveRouteRow(client, route, agent);
142
180
  if (!row || row.isArchived) return null;
143
181
  const workDir = resolveSessionWorkDir(row, route);
144
182
  if (!workDir) return '';
@@ -165,17 +203,18 @@ export function resolveWorkbenchRequest(client, msg, targetAgentId, { allowMissi
165
203
  }
166
204
 
167
205
  if (!clientSupportsRoutes || !agentSupportsRoutes) return null;
206
+ const runtimeProvider = clean(msg.workbenchRoute.runtimeProvider, 32);
168
207
  const route = {
169
- runtimeProvider: clean(msg.workbenchRoute.runtimeProvider, 32),
208
+ runtimeProvider,
170
209
  agentId: clean(msg.workbenchRoute.agentId),
171
- sessionId: clean(msg.workbenchRoute.sessionId),
210
+ ...(runtimeProvider === 'work-center'
211
+ ? { workItemId: clean(msg.workbenchRoute.workItemId) }
212
+ : { sessionId: clean(msg.workbenchRoute.sessionId) }),
172
213
  };
173
214
  const routeKey = workbenchRouteKey(route);
174
215
  if (!routeKey || route.agentId !== targetAgentId) return null;
175
216
 
176
- const row = route.runtimeProvider === 'yeaft'
177
- ? resolveYeaftRow(client, route)
178
- : resolveChatRow(client, route);
217
+ const row = resolveRouteRow(client, route, agent);
179
218
  if (row?.isArchived && !allowMissingSession) return null;
180
219
  if (!row && !allowMissingSession) return null;
181
220
 
@@ -189,6 +228,9 @@ export function resolveWorkbenchRequest(client, msg, targetAgentId, { allowMissi
189
228
  ? workbenchWorkspaceGeneration(routeKey, sessionWorkDir)
190
229
  : clean(msg.workbenchWorkspaceGeneration, 1600);
191
230
  if (!workspaceGeneration && !allowMissingSession) return null;
231
+ if (runtimeProvider === 'work-center' && !allowMissingSession
232
+ && msg.workbenchWorkspaceGeneration
233
+ && msg.workbenchWorkspaceGeneration !== workspaceGeneration) return null;
192
234
  return {
193
235
  legacy: false,
194
236
  route,
@@ -196,10 +238,12 @@ export function resolveWorkbenchRequest(client, msg, targetAgentId, { allowMissi
196
238
  scope,
197
239
  agentId: route.agentId,
198
240
  conversationId: workbenchConversationId(route, scope),
199
- // Terminal is pinned to this Server-owned cwd. Git and Files retain their
200
- // existing Agent-path picker and use requestedWorkDir after route auth.
241
+ // A WorkItem owns one canonical workspace. Unlike Session Workbench routes,
242
+ // Files/Git cannot switch this route to an arbitrary browser-provided cwd.
201
243
  workDir: sessionWorkDir,
202
- requestedWorkDir: clean(msg.workDir, 4096) || sessionWorkDir,
244
+ requestedWorkDir: route.runtimeProvider === 'work-center'
245
+ ? sessionWorkDir
246
+ : clean(msg.workDir, 4096) || sessionWorkDir,
203
247
  workspaceGeneration,
204
248
  archived: row?.isArchived === true,
205
249
  };
@@ -16,6 +16,7 @@ import {
16
16
  BROWSER_RUNTIME_PROTOCOL,
17
17
  BROWSER_RUNTIME_SETUP_PROTOCOL,
18
18
  WORKBENCH_ROUTE_PROTOCOL,
19
+ WORK_CENTER_WORKBENCH_PROTOCOL,
19
20
  } from './client-protocol.js';
20
21
  import {
21
22
  clearWorkbenchCorrelationsForClient,
@@ -112,6 +113,7 @@ export function handleWebConnection(ws, url, req = {}) {
112
113
  encryptOutbound: true,
113
114
  // Explicit protocols have no omission-based downgrade for security fields.
114
115
  workbenchRouteProtocol: 0,
116
+ workCenterWorkbenchProtocol: 0,
115
117
  browserRuntimeProtocol: 0,
116
118
  browserRuntimeSetupProtocol: 0,
117
119
  });
@@ -141,6 +143,7 @@ export function handleWebConnection(ws, url, req = {}) {
141
143
  acceptPlaintext: true,
142
144
  yeaftSessionInventoryComplete: true,
143
145
  workbenchRouteProtocol: WORKBENCH_ROUTE_PROTOCOL,
146
+ workCenterWorkbenchProtocol: WORK_CENTER_WORKBENCH_PROTOCOL,
144
147
  browserRuntimeProtocol: BROWSER_RUNTIME_PROTOCOL,
145
148
  browserRuntimeSetupProtocol: BROWSER_RUNTIME_SETUP_PROTOCOL,
146
149
  browserRuntimeEnabled: CONFIG.browserRuntime.enabled,
@@ -259,6 +262,7 @@ async function handleWebMessage(clientId, msg) {
259
262
  await sendToWebClient(client, {
260
263
  type: 'client_hello_ack',
261
264
  workbenchRouteProtocol: client.workbenchRouteProtocol,
265
+ workCenterWorkbenchProtocol: client.workCenterWorkbenchProtocol,
262
266
  browserRuntimeProtocol: client.browserRuntimeProtocol,
263
267
  browserRuntimeSetupProtocol: client.browserRuntimeSetupProtocol,
264
268
  browserRuntimeEnabled: CONFIG.browserRuntime.enabled,
@@ -1 +1 @@
1
- {"version":"1.0.550"}
1
+ {"version":"1.0.551"}