@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 +1 -1
- package/local-runtime/server/client-protocol.js +4 -0
- package/local-runtime/server/handlers/agent-work-center.js +2 -0
- package/local-runtime/server/handlers/client-work-center.js +7 -0
- package/local-runtime/server/handlers/client-workbench.js +36 -1
- package/local-runtime/server/work-center-workspace-cache.js +63 -0
- package/local-runtime/server/workbench-preview.js +1 -1
- package/local-runtime/server/workbench-route.js +62 -18
- package/local-runtime/server/ws-client.js +4 -0
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +173 -152
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/workbench/file-ops.js +25 -13
- package/workbench/file-search.js +4 -1
- package/workbench/git-ops.js +32 -26
- package/workbench/work-item-path.js +41 -0
- package/yeaft/work-center/projection.js +5 -0
|
Binary file
|
package/package.json
CHANGED
package/workbench/file-ops.js
CHANGED
|
@@ -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 =
|
|
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,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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, {
|
|
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 =
|
|
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);
|
package/workbench/file-search.js
CHANGED
|
@@ -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 =
|
|
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();
|
package/workbench/git-ops.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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),
|