@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.550",
3
+ "version": "1.0.551",
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",
@@ -4,6 +4,7 @@ import { join, basename, dirname, extname, isAbsolute, relative, resolve } from
4
4
  import { platform } from 'os';
5
5
  import ctx from '../context.js';
6
6
  import { resolveAndValidatePath, BINARY_EXTENSIONS, VIDEO_EXTENSIONS } from './utils.js';
7
+ import { resolveWorkItemPath } from './work-item-path.js';
7
8
  import { sendWorkbenchResult } from './request-routing.js';
8
9
 
9
10
  export const MAX_WORKBENCH_PREVIEW_BYTES = 20 * 1024 * 1024;
@@ -271,7 +272,7 @@ export async function handleWriteFile(msg) {
271
272
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
272
273
 
273
274
  try {
274
- const resolved = resolveAndValidatePath(filePath, workDir);
275
+ const resolved = await resolveWorkItemPath(msg, filePath, workDir);
275
276
  await writeFile(resolved, content, 'utf-8');
276
277
 
277
278
  sendWorkbenchResult(ctx, msg, {
@@ -300,7 +301,8 @@ export async function handleWriteFile(msg) {
300
301
  }
301
302
 
302
303
  export async function handleListDirectory(msg) {
303
- const { conversationId, requestId, dirPath, _requestUserId, _requestClientId } = msg;
304
+ const { conversationId, requestId, _requestUserId, _requestClientId } = msg;
305
+ const dirPath = msg.dirPath || (msg.workbenchRoute?.runtimeProvider === 'work-center' ? msg.workDir : msg.dirPath);
304
306
  const directoryPickerScope = msg.directoryPickerScope === 'agent' ? 'agent' : undefined;
305
307
  const conv = ctx.conversations.get(conversationId);
306
308
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
@@ -362,7 +364,7 @@ export async function handleListDirectory(msg) {
362
364
  }
363
365
 
364
366
  try {
365
- const resolved = resolveAndValidatePath(dirPath, workDir);
367
+ const resolved = await resolveWorkItemPath(msg, dirPath, workDir);
366
368
  const entries = await readdir(resolved, { withFileTypes: true });
367
369
  const result = [];
368
370
 
@@ -426,7 +428,7 @@ export async function handleCreateFile(msg) {
426
428
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
427
429
 
428
430
  try {
429
- const resolved = resolveAndValidatePath(filePath, workDir);
431
+ const resolved = await resolveWorkItemPath(msg, filePath, workDir);
430
432
  if (isDirectory) {
431
433
  await mkdir(resolved, { recursive: true });
432
434
  } else {
@@ -464,7 +466,7 @@ export async function handleDeleteFiles(msg) {
464
466
 
465
467
  for (const p of paths) {
466
468
  try {
467
- const resolved = resolveAndValidatePath(p, workDir);
469
+ const resolved = await resolveWorkItemPath(msg, p, workDir);
468
470
  const s = await stat(resolved);
469
471
  if (s.isDirectory()) {
470
472
  await rm(resolved, { recursive: true, force: true });
@@ -503,7 +505,7 @@ export async function handleMoveFiles(msg) {
503
505
  if (!paths || paths.length === 0) throw new Error('No paths specified');
504
506
  if (!destination) throw new Error('No destination specified');
505
507
 
506
- const destResolved = resolveAndValidatePath(destination, workDir);
508
+ const destResolved = await resolveWorkItemPath(msg, destination, workDir);
507
509
  // Ensure destination directory exists
508
510
  await mkdir(destResolved, { recursive: true });
509
511
 
@@ -512,9 +514,9 @@ export async function handleMoveFiles(msg) {
512
514
 
513
515
  for (const p of paths) {
514
516
  try {
515
- const srcResolved = resolveAndValidatePath(p, workDir);
517
+ const srcResolved = await resolveWorkItemPath(msg, p, workDir);
516
518
  const name = (newName && paths.length === 1) ? newName : basename(srcResolved);
517
- const destPath = join(destResolved, name);
519
+ const destPath = await resolveWorkItemPath(msg, join(destResolved, name), workDir);
518
520
  if (existsSync(destPath)) {
519
521
  throw new Error('Target already exists: ' + name);
520
522
  }
@@ -551,7 +553,7 @@ export async function handleCopyFiles(msg) {
551
553
  if (!paths || paths.length === 0) throw new Error('No paths specified');
552
554
  if (!destination) throw new Error('No destination specified');
553
555
 
554
- const destResolved = resolveAndValidatePath(destination, workDir);
556
+ const destResolved = await resolveWorkItemPath(msg, destination, workDir);
555
557
  await mkdir(destResolved, { recursive: true });
556
558
 
557
559
  const copied = [];
@@ -559,7 +561,7 @@ export async function handleCopyFiles(msg) {
559
561
 
560
562
  for (const p of paths) {
561
563
  try {
562
- const srcResolved = resolveAndValidatePath(p, workDir);
564
+ const srcResolved = await resolveWorkItemPath(msg, p, workDir);
563
565
  const name = basename(srcResolved);
564
566
  let destPath = join(destResolved, name);
565
567
 
@@ -574,9 +576,19 @@ export async function handleCopyFiles(msg) {
574
576
  } while (existsSync(destPath));
575
577
  }
576
578
 
579
+ await resolveWorkItemPath(msg, destPath, workDir);
577
580
  const srcStat = await stat(srcResolved);
578
581
  if (srcStat.isDirectory()) {
579
- await cp(srcResolved, destPath, { recursive: true });
582
+ await cp(srcResolved, destPath, {
583
+ recursive: true,
584
+ ...(msg.workbenchRoute?.runtimeProvider === 'work-center' ? {
585
+ filter: async (source, target) => {
586
+ await resolveWorkItemPath(msg, source, workDir);
587
+ await resolveWorkItemPath(msg, target, workDir);
588
+ return true;
589
+ },
590
+ } : {}),
591
+ });
580
592
  } else {
581
593
  await copyFile(srcResolved, destPath);
582
594
  }
@@ -611,7 +623,7 @@ export async function handleUploadToDir(msg) {
611
623
  try {
612
624
  if (!files || files.length === 0) throw new Error('No files specified');
613
625
 
614
- const targetDir = resolveAndValidatePath(dirPath || workDir, workDir);
626
+ const targetDir = await resolveWorkItemPath(msg, dirPath || workDir, workDir);
615
627
  await mkdir(targetDir, { recursive: true });
616
628
 
617
629
  const saved = [];
@@ -619,7 +631,7 @@ export async function handleUploadToDir(msg) {
619
631
 
620
632
  for (const file of files) {
621
633
  try {
622
- const dest = join(targetDir, file.name);
634
+ const dest = await resolveWorkItemPath(msg, join(targetDir, file.name), workDir);
623
635
  const buffer = Buffer.from(file.data, 'base64');
624
636
  await writeFile(dest, buffer);
625
637
  saved.push(file.name);
@@ -1,6 +1,7 @@
1
1
  import { readdir, stat } from 'fs/promises';
2
2
  import { join, relative, resolve } from 'path';
3
3
  import ctx from '../context.js';
4
+ import { resolveWorkItemPath } from './work-item-path.js';
4
5
  import { sendWorkbenchResult } from './request-routing.js';
5
6
 
6
7
  export async function handleFileSearch(msg) {
@@ -15,7 +16,9 @@ export async function handleFileSearch(msg) {
15
16
  return;
16
17
  }
17
18
 
18
- const resolved = resolve(searchRoot);
19
+ const resolved = msg.workbenchRoute?.runtimeProvider === 'work-center'
20
+ ? await resolveWorkItemPath(msg, searchRoot, workDir)
21
+ : resolve(searchRoot);
19
22
  const results = [];
20
23
  const MAX_RESULTS = 100;
21
24
  const lowerQuery = query.toLowerCase();
@@ -1,9 +1,28 @@
1
- import { readFile, writeFile } from 'fs/promises';
2
- import { join, resolve } from 'path';
1
+ import { readFile, realpath, writeFile } from 'fs/promises';
2
+ import { join, relative, resolve } from 'path';
3
3
  import ctx from '../context.js';
4
4
  import { execAsync, resolveAndValidatePath, getGitRoot, validateGitPath } from './utils.js';
5
+ import { resolveWorkItemPath } from './work-item-path.js';
5
6
  import { sendWorkbenchResult } from './request-routing.js';
6
7
 
8
+ // Git paths and bulk operations are repository-scoped. A WorkItem must own
9
+ // that root, rather than silently widening a subdirectory workspace to it.
10
+ async function getWorkbenchGitRoot(msg, workDir) {
11
+ if (msg.workbenchRoute?.runtimeProvider !== 'work-center') return getGitRoot(workDir);
12
+ // Do not fall back to cwd on lookup failure: Git itself may ascend from it.
13
+ const { stdout } = await execAsync('git rev-parse --show-toplevel', {
14
+ cwd: workDir, timeout: 5000, windowsHide: true,
15
+ });
16
+ const gitRoot = stdout.trim();
17
+ const [workspace, repository] = await Promise.all([realpath(workDir), realpath(gitRoot)]);
18
+ if (relative(workspace, repository) !== '') {
19
+ const error = new Error('Git requires the WorkItem workspace to be the repository root. Files and Terminal remain available.');
20
+ error.code = 'WORK_ITEM_GIT_ROOT_REQUIRED';
21
+ throw error;
22
+ }
23
+ return gitRoot;
24
+ }
25
+
7
26
  export async function handleGitStatus(msg) {
8
27
  const { conversationId, _requestUserId } = msg;
9
28
  const conv = ctx.conversations.get(conversationId);
@@ -11,15 +30,7 @@ export async function handleGitStatus(msg) {
11
30
 
12
31
  try {
13
32
  // Get git repo root to ensure paths are consistent
14
- let gitRoot = workDir;
15
- try {
16
- const { stdout: rootOut } = await execAsync('git rev-parse --show-toplevel', {
17
- cwd: workDir,
18
- timeout: 5000,
19
- windowsHide: true
20
- });
21
- gitRoot = rootOut.trim();
22
- } catch {}
33
+ const gitRoot = await getWorkbenchGitRoot(msg, workDir);
23
34
 
24
35
  const { stdout: statusOut } = await execAsync('git status --porcelain', {
25
36
  cwd: gitRoot,
@@ -76,6 +87,7 @@ export async function handleGitStatus(msg) {
76
87
  conversationId,
77
88
  _requestUserId,
78
89
  error: e.message,
90
+ ...(e.code ? { errorCode: e.code } : {}),
79
91
  isGitRepo: !e.message.includes('not a git repository') && !e.message.includes('ENOENT')
80
92
  });
81
93
  }
@@ -100,20 +112,14 @@ export async function handleGitDiff(msg) {
100
112
  }
101
113
 
102
114
  // Get git repo root — git status paths are relative to this, not workDir
103
- let gitRoot = workDir;
104
- try {
105
- const { stdout: rootOut } = await execAsync('git rev-parse --show-toplevel', {
106
- cwd: workDir,
107
- timeout: 5000,
108
- windowsHide: true
109
- });
110
- gitRoot = rootOut.trim();
111
- } catch {}
115
+ const gitRoot = await getWorkbenchGitRoot(msg, workDir);
112
116
 
113
117
  if (untracked) {
114
118
  // Untracked files: resolve path relative to git root
115
119
  const fullPath = resolve(gitRoot, filePath);
116
- const resolved = resolveAndValidatePath(fullPath, gitRoot);
120
+ // The root check above proved canonical workspace ownership. Validate
121
+ // against that same Git root, including when cwd is a symlink alias.
122
+ const resolved = await resolveWorkItemPath(msg, resolveAndValidatePath(fullPath, gitRoot), gitRoot);
117
123
  const content = await readFile(resolved, 'utf-8');
118
124
  sendWorkbenchResult(ctx, msg, {
119
125
  type: 'git_diff_result',
@@ -205,7 +211,7 @@ export async function handleGitAdd(msg) {
205
211
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
206
212
 
207
213
  try {
208
- const gitRoot = await getGitRoot(workDir);
214
+ const gitRoot = await getWorkbenchGitRoot(msg, workDir);
209
215
 
210
216
  if (addAll) {
211
217
  await execAsync('git add -A', { cwd: gitRoot, timeout: 10000, windowsHide: true });
@@ -229,7 +235,7 @@ export async function handleGitReset(msg) {
229
235
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
230
236
 
231
237
  try {
232
- const gitRoot = await getGitRoot(workDir);
238
+ const gitRoot = await getWorkbenchGitRoot(msg, workDir);
233
239
 
234
240
  if (resetAll) {
235
241
  await execAsync('git reset HEAD', { cwd: gitRoot, timeout: 10000, windowsHide: true });
@@ -258,7 +264,7 @@ export async function handleGitRestore(msg) {
258
264
  return;
259
265
  }
260
266
 
261
- const gitRoot = await getGitRoot(workDir);
267
+ const gitRoot = await getWorkbenchGitRoot(msg, workDir);
262
268
  await execAsync(`git restore -- "${filePath}"`, { cwd: gitRoot, timeout: 10000, windowsHide: true });
263
269
  sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'restore', success: true, message: `Restored: ${filePath}` });
264
270
  } catch (e) {
@@ -277,7 +283,7 @@ export async function handleGitCommit(msg) {
277
283
  return;
278
284
  }
279
285
 
280
- const gitRoot = await getGitRoot(workDir);
286
+ const gitRoot = await getWorkbenchGitRoot(msg, workDir);
281
287
 
282
288
  // Write commit message to temp file to avoid shell injection
283
289
  const tmpFile = join(gitRoot, '.git', 'WEBCHAT_COMMIT_MSG');
@@ -303,7 +309,7 @@ export async function handleGitPush(msg) {
303
309
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
304
310
 
305
311
  try {
306
- const gitRoot = await getGitRoot(workDir);
312
+ const gitRoot = await getWorkbenchGitRoot(msg, workDir);
307
313
  const { stdout, stderr } = await execAsync('git push', {
308
314
  cwd: gitRoot, timeout: 60000, windowsHide: true
309
315
  });
@@ -0,0 +1,41 @@
1
+ import { lstat, realpath } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
3
+
4
+ function assertWithin(root, candidate) {
5
+ const path = relative(root, candidate);
6
+ if (path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) {
7
+ const error = new Error('File is outside the WorkItem workspace.');
8
+ error.code = 'FILE_OUTSIDE_WORKSPACE';
9
+ throw error;
10
+ }
11
+ }
12
+
13
+ /**
14
+ * Resolve a WorkItem file operation without following a symlink outside its
15
+ * Agent-resolved workspace. New destinations validate the nearest existing
16
+ * ancestor; dangling symlinks fail closed. Other Workbench routes keep their
17
+ * existing path-picker behavior. This is not a concurrent filesystem sandbox.
18
+ */
19
+ export async function resolveWorkItemPath(msg, filePath, workDir) {
20
+ const candidate = resolve(workDir, filePath);
21
+ if (msg.workbenchRoute?.runtimeProvider !== 'work-center') return candidate;
22
+ if (!isAbsolute(workDir)) throw new Error('WorkItem workspace must be absolute.');
23
+ const root = await realpath(workDir);
24
+ // Check both lexical and canonical ownership. Return the original path so a
25
+ // delete/rename still operates on a symlink itself, not on its target.
26
+ assertWithin(resolve(workDir), candidate);
27
+ let ancestor = candidate;
28
+ for (;;) {
29
+ try {
30
+ await lstat(ancestor);
31
+ } catch (error) {
32
+ if (error.code !== 'ENOENT') throw error;
33
+ const parent = dirname(ancestor);
34
+ if (parent === ancestor) throw error;
35
+ ancestor = parent;
36
+ continue;
37
+ }
38
+ assertWithin(root, await realpath(ancestor));
39
+ return candidate;
40
+ }
41
+ }
@@ -970,6 +970,11 @@ export function projectWorkItemDetail(detail, options = {}) {
970
970
  }
971
971
  const projected = {
972
972
  id: detail.id,
973
+ // The browser needs the Agent-resolved workspace only to bind an explicitly
974
+ // selected WorkItem Workbench. It is not included in board/list projections.
975
+ workbench: typeof detail.workDir === 'string' && detail.workDir.trim()
976
+ ? { workDir: detail.workDir.trim() }
977
+ : null,
973
978
  revision: detail.revision,
974
979
  planRevision: count(detail.planRevision),
975
980
  ledgerRevision: count(detail.ledgerRevision),