@yeaft/webchat-agent 1.0.154 → 1.0.156

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.154",
3
+ "version": "1.0.156",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -103,7 +103,7 @@ export async function handleWriteFile(msg) {
103
103
  }
104
104
 
105
105
  export async function handleListDirectory(msg) {
106
- const { conversationId, dirPath, _requestUserId, _requestClientId } = msg;
106
+ const { conversationId, requestId, dirPath, _requestUserId, _requestClientId } = msg;
107
107
  const conv = ctx.conversations.get(conversationId);
108
108
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
109
109
 
@@ -121,6 +121,7 @@ export async function handleListDirectory(msg) {
121
121
  ctx.sendToServer({
122
122
  type: 'directory_listing',
123
123
  conversationId,
124
+ requestId,
124
125
  _requestUserId,
125
126
  _requestClientId,
126
127
  dirPath: '',
@@ -137,6 +138,7 @@ export async function handleListDirectory(msg) {
137
138
  ctx.sendToServer({
138
139
  type: 'directory_listing',
139
140
  conversationId,
141
+ requestId,
140
142
  _requestUserId,
141
143
  _requestClientId,
142
144
  dirPath: '/',
@@ -147,6 +149,7 @@ export async function handleListDirectory(msg) {
147
149
  ctx.sendToServer({
148
150
  type: 'directory_listing',
149
151
  conversationId,
152
+ requestId,
150
153
  _requestUserId,
151
154
  _requestClientId,
152
155
  dirPath: '',
@@ -194,6 +197,7 @@ export async function handleListDirectory(msg) {
194
197
  ctx.sendToServer({
195
198
  type: 'directory_listing',
196
199
  conversationId,
200
+ requestId,
197
201
  _requestUserId,
198
202
  _requestClientId,
199
203
  dirPath: resolved,
@@ -203,6 +207,7 @@ export async function handleListDirectory(msg) {
203
207
  ctx.sendToServer({
204
208
  type: 'directory_listing',
205
209
  conversationId,
210
+ requestId,
206
211
  _requestUserId,
207
212
  _requestClientId,
208
213
  dirPath: dirPath || workDir,
@@ -9,6 +9,7 @@ import {
9
9
  readFileSync,
10
10
  realpathSync,
11
11
  rmSync,
12
+ unlinkSync,
12
13
  writeFileSync,
13
14
  } from 'node:fs';
14
15
  import { createHash, randomUUID } from 'node:crypto';
@@ -175,6 +176,43 @@ function removeCreatedDirectory(state) {
175
176
  }
176
177
  }
177
178
 
179
+ function openAttachmentDirectory(root, workItemId) {
180
+ if (process.platform !== 'linux') {
181
+ throw new Error('Secure WorkItem attachment access requires Linux');
182
+ }
183
+ const { attachmentRoot, itemDirectory } = attachmentDirectory(root, workItemId);
184
+ const rootDescriptor = openDirectory(attachmentRoot, 'WorkItem attachment root');
185
+ try {
186
+ const ownerName = safeWorkItemId(workItemId);
187
+ const itemDescriptor = openDirectory(
188
+ `/proc/self/fd/${rootDescriptor}/${ownerName}`,
189
+ 'WorkItem attachment owner directory',
190
+ );
191
+ try {
192
+ assertDescriptorMatchesPath(rootDescriptor, attachmentRoot, 'WorkItem attachment root');
193
+ assertDescriptorMatchesPath(itemDescriptor, itemDirectory, 'WorkItem attachment owner directory');
194
+ return { attachmentRoot, itemDirectory, ownerName, rootDescriptor, itemDescriptor };
195
+ } catch (error) {
196
+ closeSync(itemDescriptor);
197
+ throw error;
198
+ }
199
+ } catch (error) {
200
+ closeSync(rootDescriptor);
201
+ throw error;
202
+ }
203
+ }
204
+
205
+ function removeAttachmentFile(directoryState, storageName) {
206
+ if (!/^[A-Za-z0-9_-]+(?:\.[a-z0-9]{1,10})?$/.test(storageName)) return;
207
+ assertDescriptorMatchesPath(directoryState.rootDescriptor, directoryState.attachmentRoot, 'WorkItem attachment root');
208
+ assertDescriptorMatchesPath(directoryState.itemDescriptor, directoryState.itemDirectory, 'WorkItem attachment owner directory');
209
+ try {
210
+ unlinkSync(`/proc/self/fd/${directoryState.itemDescriptor}/${storageName}`);
211
+ } catch (error) {
212
+ if (error?.code !== 'ENOENT') throw error;
213
+ }
214
+ }
215
+
178
216
  export function persistWorkItemAttachments(files, options = {}) {
179
217
  if (!Array.isArray(files) || files.length === 0) return [];
180
218
  if (files.length > MAX_WORK_ITEM_ATTACHMENTS) {
@@ -226,6 +264,74 @@ export function persistWorkItemAttachments(files, options = {}) {
226
264
  }
227
265
  }
228
266
 
267
+ export function appendWorkItemAttachments(existing, files, options = {}) {
268
+ if (!Array.isArray(files) || files.length === 0) return [];
269
+ const current = Array.isArray(existing) ? existing : [];
270
+ if (current.length === 0) return persistWorkItemAttachments(files, options);
271
+ if (current.length + files.length > MAX_WORK_ITEM_ATTACHMENTS) {
272
+ throw new Error(`WorkItem supports at most ${MAX_WORK_ITEM_ATTACHMENTS} attachments`);
273
+ }
274
+
275
+ let totalBytes = current.reduce((total, attachment) => total + (Number(attachment?.size) || 0), 0);
276
+ const prepared = files.map((file, index) => {
277
+ if (!file || typeof file !== 'object' || Array.isArray(file)) {
278
+ throw new Error(`Attachment ${index + 1} is invalid`);
279
+ }
280
+ const name = safeDisplayName(file.name, current.length + index);
281
+ const mimeType = typeof file.mimeType === 'string' && file.mimeType.trim()
282
+ ? file.mimeType.trim().slice(0, 255)
283
+ : 'application/octet-stream';
284
+ const kind = assertSupportedWorkItemAttachment(name, mimeType);
285
+ const buffer = decodeBase64(file.data);
286
+ assertWorkItemAttachmentSize(buffer.length);
287
+ totalBytes += buffer.length;
288
+ if (totalBytes > MAX_WORK_ITEM_ATTACHMENT_BYTES) {
289
+ throw new Error(`WorkItem attachments exceed ${MAX_WORK_ITEM_ATTACHMENT_BYTES} bytes`);
290
+ }
291
+ const id = randomUUID();
292
+ return {
293
+ buffer,
294
+ attachment: {
295
+ id,
296
+ name,
297
+ storageName: `${id}${safeExtension(name)}`,
298
+ mimeType,
299
+ size: buffer.length,
300
+ sha256: digest(buffer),
301
+ kind,
302
+ isImage: kind === 'image',
303
+ },
304
+ };
305
+ });
306
+
307
+ const directoryState = openAttachmentDirectory(options.root, options.workItemId);
308
+ const written = [];
309
+ try {
310
+ for (const entry of prepared) {
311
+ writeAttachmentFile(directoryState, entry.attachment.storageName, entry.buffer);
312
+ written.push(entry.attachment);
313
+ }
314
+ return written;
315
+ } catch (error) {
316
+ for (const attachment of written) {
317
+ try { removeAttachmentFile(directoryState, attachment.storageName); } catch {}
318
+ }
319
+ throw error;
320
+ } finally {
321
+ closeDirectoryState(directoryState);
322
+ }
323
+ }
324
+
325
+ export function removeWorkItemAttachmentFiles(root, workItemId, attachments) {
326
+ if (!Array.isArray(attachments) || attachments.length === 0) return;
327
+ const directoryState = openAttachmentDirectory(root, workItemId);
328
+ try {
329
+ for (const attachment of attachments) removeAttachmentFile(directoryState, attachment?.storageName);
330
+ } finally {
331
+ closeDirectoryState(directoryState);
332
+ }
333
+ }
334
+
229
335
  export function removeWorkItemAttachments(root, workItemId, options = {}) {
230
336
  if (!root || !workItemId) return;
231
337
  if (process.platform !== 'linux') {
@@ -286,6 +392,26 @@ function resolveAttachmentPath(root, workItemId, attachment) {
286
392
  return { filePath: actualPath, size: stat.size, itemDirectory: itemRoot };
287
393
  }
288
394
 
395
+ export function readWorkItemAttachment(workItem, attachmentId, options = {}) {
396
+ const attachment = Array.isArray(workItem?.attachments)
397
+ ? workItem.attachments.find(item => item?.id === attachmentId)
398
+ : null;
399
+ if (!attachment) throw new Error('WorkItem attachment not found');
400
+ const resolved = resolveAttachmentPath(options.root, workItem.id, attachment);
401
+ const buffer = readFileSync(resolved.filePath);
402
+ if (resolved.size !== Number(attachment.size) || digest(buffer) !== attachment.sha256) {
403
+ throw new Error(`WorkItem attachment changed after creation: ${attachment.name || attachment.id}`);
404
+ }
405
+ return {
406
+ id: attachment.id,
407
+ name: attachment.name,
408
+ mimeType: attachment.mimeType,
409
+ size: resolved.size,
410
+ isImage: attachment.isImage === true,
411
+ data: buffer.toString('base64'),
412
+ };
413
+ }
414
+
289
415
  function escapePromptText(value) {
290
416
  return String(value || '')
291
417
  .replace(/&/g, '&')
@@ -20,21 +20,18 @@ let serviceFactory = null;
20
20
  const BROWSER_DETAIL_OPS = new Set(['get', 'create', 'update', 'start', 'cancel', 'guide', 'retry']);
21
21
  // `files` is an internal server-to-Agent field. The browser relay rejects any
22
22
  // client-supplied value and only emits files resolved from owned upload ids.
23
- const BROWSER_CREATE_FIELDS = Object.freeze([
24
- 'title',
25
- 'goal',
26
- 'acceptanceCriteria',
27
- 'workDir',
28
- 'reuseMemory',
29
- 'origin',
30
- 'linkedSessionIds',
31
- 'files',
32
- 'start',
33
- ]);
34
-
35
- function browserCreatePayload(value) {
23
+ const BROWSER_FILE_FIELDS = Object.freeze({
24
+ create: [
25
+ 'title', 'goal', 'acceptanceCriteria', 'workDir', 'reuseMemory', 'origin',
26
+ 'linkedSessionIds', 'files', 'start',
27
+ ],
28
+ guide: ['id', 'guidance', 'actionId', 'revision', 'files'],
29
+ });
30
+
31
+ function browserFilePayload(op, value) {
36
32
  const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
37
- return Object.fromEntries(BROWSER_CREATE_FIELDS
33
+ const fields = BROWSER_FILE_FIELDS[op] || [];
34
+ return Object.fromEntries(fields
38
35
  .filter(field => Object.prototype.hasOwnProperty.call(source, field))
39
36
  .map(field => [field, source[field]]));
40
37
  }
@@ -176,10 +173,10 @@ export async function handleWorkCenterRequest(msg) {
176
173
  data = await readSettingsResponse();
177
174
  } else {
178
175
  const workCenter = await ensureWorkCenter();
179
- data = await workCenter.handle(
180
- op,
181
- op === 'create' ? browserCreatePayload(msg.payload) : (msg.payload || {}),
182
- );
176
+ const payload = Object.hasOwn(BROWSER_FILE_FIELDS, op)
177
+ ? browserFilePayload(op, msg.payload)
178
+ : (msg.payload || {});
179
+ data = await workCenter.handle(op, payload);
183
180
  }
184
181
  if (BROWSER_DETAIL_OPS.has(op)) data = projectWorkItemDetail(data);
185
182
  send({
@@ -172,7 +172,9 @@ export class WorkflowController {
172
172
 
173
173
  guide(id, input = {}) {
174
174
  const guidance = typeof input.guidance === 'string' ? input.guidance.trim().slice(0, 8_000) : '';
175
- if (!guidance) throw new Error('guidance is required');
175
+ const addedAttachmentCount = Math.max(0, Number(input.addedAttachmentCount) || 0);
176
+ if (!guidance && addedAttachmentCount === 0) throw new Error('guidance or attachments are required');
177
+ const guidanceSummary = guidance || `The user added ${addedAttachmentCount} attachment(s) as additional context for this Action.`;
176
178
  const expected = {
177
179
  actionId: typeof input.actionId === 'string' ? input.actionId : '',
178
180
  revision: Number(input.revision),
@@ -180,11 +182,11 @@ export class WorkflowController {
180
182
  if (!expected.actionId || !Number.isInteger(expected.revision)) {
181
183
  throw new Error('actionId and revision are required for guidance');
182
184
  }
183
- const detail = this.store.addActionGuidance(id, guidance, expected, (workItem, previous) => {
185
+ const detail = this.store.addActionGuidance(id, guidanceSummary, expected, (workItem, previous) => {
184
186
  const context = [...(previous.context || []), {
185
187
  type: 'guidance',
186
188
  role: 'user',
187
- summary: guidance,
189
+ summary: guidanceSummary,
188
190
  evidence: [],
189
191
  }];
190
192
  const step = {
@@ -200,7 +202,7 @@ export class WorkflowController {
200
202
  instruction: actionInstruction(step, workItem, context),
201
203
  maxAttempts: previous.maxAttempts || 2,
202
204
  };
203
- });
205
+ }, input.attachments);
204
206
  if (!detail) throw new Error(`WorkItem not found: ${id}`);
205
207
  return detail;
206
208
  }
@@ -5,7 +5,10 @@ import { WorkItemStore } from './store.js';
5
5
  import { WorkflowController } from './controller.js';
6
6
  import { WorkItemWatcher } from './watcher.js';
7
7
  import {
8
+ appendWorkItemAttachments,
8
9
  persistWorkItemAttachments,
10
+ readWorkItemAttachment,
11
+ removeWorkItemAttachmentFiles,
9
12
  removeWorkItemAttachments,
10
13
  } from './attachments.js';
11
14
  import { projectWorkItemDetail, projectWorkItemSummary } from './projection.js';
@@ -157,15 +160,57 @@ export class WorkCenterService {
157
160
  }
158
161
  case 'guide': {
159
162
  const id = requiredString(payload.id, 'id');
160
- const detail = this.controller.guide(id, {
161
- guidance: typeof payload.guidance === 'string' ? payload.guidance : '',
162
- actionId: typeof payload.actionId === 'string' ? payload.actionId : '',
163
- revision: payload.revision,
164
- });
163
+ const workItem = this.#requiredItem(id);
164
+ let addedAttachments = [];
165
+ let detail;
166
+ try {
167
+ addedAttachments = appendWorkItemAttachments(workItem.attachments, payload.files, {
168
+ root: this.attachmentRoot,
169
+ workItemId: id,
170
+ });
171
+ detail = this.controller.guide(id, {
172
+ guidance: typeof payload.guidance === 'string' ? payload.guidance : '',
173
+ actionId: typeof payload.actionId === 'string' ? payload.actionId : '',
174
+ revision: payload.revision,
175
+ addedAttachmentCount: addedAttachments.length,
176
+ attachments: [...(workItem.attachments || []), ...addedAttachments],
177
+ });
178
+ } catch (error) {
179
+ try {
180
+ if ((workItem.attachments || []).length === 0 && addedAttachments.length > 0) {
181
+ removeWorkItemAttachments(this.attachmentRoot, id);
182
+ } else {
183
+ removeWorkItemAttachmentFiles(this.attachmentRoot, id, addedAttachments);
184
+ }
185
+ } catch {}
186
+ throw error;
187
+ }
165
188
  this.watcher.abortInvalidWorkItemRuns(id);
166
189
  this.#emit({ type: 'action.guidance_added', workItem: detail });
167
190
  return detail;
168
191
  }
192
+ case 'preview_attachment': {
193
+ const id = requiredString(payload.id, 'id');
194
+ const attachment = readWorkItemAttachment(
195
+ this.#requiredItem(id),
196
+ requiredString(payload.attachmentId, 'attachmentId'),
197
+ { root: this.attachmentRoot },
198
+ );
199
+ return {
200
+ attachment: {
201
+ id: attachment.id,
202
+ name: attachment.name,
203
+ mimeType: attachment.mimeType,
204
+ size: attachment.size,
205
+ isImage: attachment.isImage,
206
+ },
207
+ previewData: {
208
+ data: attachment.data,
209
+ mimeType: attachment.mimeType,
210
+ filename: attachment.name,
211
+ },
212
+ };
213
+ }
169
214
  case 'retry': {
170
215
  const detail = this.controller.retry(requiredString(payload.id, 'id'), {
171
216
  answer: typeof payload.answer === 'string' ? payload.answer : '',
@@ -536,7 +536,7 @@ export class WorkItemStore {
536
536
  });
537
537
  }
538
538
 
539
- addActionGuidance(id, guidance, expected, makeAction) {
539
+ addActionGuidance(id, guidance, expected, makeAction, attachments = null) {
540
540
  return withTransaction(this.db, () => {
541
541
  const workItem = this.getWorkItem(id);
542
542
  if (!workItem) return null;
@@ -563,7 +563,12 @@ export class WorkItemStore {
563
563
  contractRevision: workItem.revision,
564
564
  }, this.#nextSequence(id), now);
565
565
  this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
566
- current_run_id = NULL, updated_at = ? WHERE id = ?`).run(action.id, now, id);
566
+ current_run_id = NULL, attachments = ?, updated_at = ? WHERE id = ?`).run(
567
+ action.id,
568
+ stringify(Array.isArray(attachments) ? attachments : workItem.attachments),
569
+ now,
570
+ id,
571
+ );
567
572
  this.appendEvent(id, 'action.guidance_added', { guidance }, { actionId: action.id });
568
573
  return this.getWorkItemDetail(id);
569
574
  });