aws-runtime-bridge 1.4.0 → 1.5.0
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/dist/adapter/OpencodeSdkAdapter.d.ts +13 -1
- package/dist/adapter/OpencodeSdkAdapter.d.ts.map +1 -1
- package/dist/adapter/OpencodeSdkAdapter.js +56 -4
- package/dist/adapter/OpencodeSdkAdapter.test.js +57 -1
- package/dist/routes/file-browser.d.ts +10 -0
- package/dist/routes/file-browser.d.ts.map +1 -1
- package/dist/routes/file-browser.js +206 -4
- package/dist/routes/file-browser.test.js +22 -0
- package/dist/services/session-output.d.ts +12 -0
- package/dist/services/session-output.d.ts.map +1 -1
- package/dist/services/session-output.js +15 -0
- package/dist/services/workspace-files.d.ts +72 -0
- package/dist/services/workspace-files.d.ts.map +1 -1
- package/dist/services/workspace-files.js +519 -21
- package/dist/services/workspace-files.test.js +387 -11
- package/dist/services/workspace-watch.d.ts +21 -0
- package/dist/services/workspace-watch.d.ts.map +1 -0
- package/dist/services/workspace-watch.js +123 -0
- package/dist/services/workspace-watch.test.d.ts +2 -0
- package/dist/services/workspace-watch.test.d.ts.map +1 -0
- package/dist/services/workspace-watch.test.js +38 -0
- package/package.json +8 -1
|
@@ -1,30 +1,117 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createWriteStream, promises as fs } from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
4
|
+
import * as tar from 'tar';
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
6
|
+
import { createWorkspaceEntry, deleteWorkspaceEntry, extractWorkspaceArchive, listWorkspaceDirectory, previewWorkspaceDocument, readWorkspaceFile, renameWorkspaceEntry, resolveWorkspaceDownloadTarget, streamWorkspaceDirectoryZip, uploadWorkspaceFiles, writeWorkspaceFile, } from './workspace-files.js';
|
|
7
|
+
const { ZipArchive } = await import('archiver');
|
|
8
|
+
function crc32(input) {
|
|
9
|
+
let crc = 0xffffffff;
|
|
10
|
+
for (const byte of input) {
|
|
11
|
+
crc ^= byte;
|
|
12
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
13
|
+
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
17
|
+
}
|
|
18
|
+
async function writeStoredZip(filePath, entryName, content) {
|
|
19
|
+
const nameBytes = Buffer.from(entryName, 'utf-8');
|
|
20
|
+
await writeStoredZipWithNameBytes(filePath, nameBytes, content);
|
|
21
|
+
}
|
|
22
|
+
async function writeStoredZipWithNameBytes(filePath, nameBytes, content) {
|
|
23
|
+
const contentBytes = Buffer.from(content, 'utf-8');
|
|
24
|
+
const checksum = crc32(contentBytes);
|
|
25
|
+
const localHeader = Buffer.alloc(30);
|
|
26
|
+
localHeader.writeUInt32LE(0x04034b50, 0);
|
|
27
|
+
localHeader.writeUInt16LE(20, 4);
|
|
28
|
+
localHeader.writeUInt16LE(0, 6);
|
|
29
|
+
localHeader.writeUInt16LE(0, 8);
|
|
30
|
+
localHeader.writeUInt32LE(0, 10);
|
|
31
|
+
localHeader.writeUInt32LE(checksum, 14);
|
|
32
|
+
localHeader.writeUInt32LE(contentBytes.length, 18);
|
|
33
|
+
localHeader.writeUInt32LE(contentBytes.length, 22);
|
|
34
|
+
localHeader.writeUInt16LE(nameBytes.length, 26);
|
|
35
|
+
localHeader.writeUInt16LE(0, 28);
|
|
36
|
+
const centralHeader = Buffer.alloc(46);
|
|
37
|
+
centralHeader.writeUInt32LE(0x02014b50, 0);
|
|
38
|
+
centralHeader.writeUInt16LE(20, 4);
|
|
39
|
+
centralHeader.writeUInt16LE(20, 6);
|
|
40
|
+
centralHeader.writeUInt16LE(0, 8);
|
|
41
|
+
centralHeader.writeUInt16LE(0, 10);
|
|
42
|
+
centralHeader.writeUInt32LE(0, 12);
|
|
43
|
+
centralHeader.writeUInt32LE(checksum, 16);
|
|
44
|
+
centralHeader.writeUInt32LE(contentBytes.length, 20);
|
|
45
|
+
centralHeader.writeUInt32LE(contentBytes.length, 24);
|
|
46
|
+
centralHeader.writeUInt16LE(nameBytes.length, 28);
|
|
47
|
+
centralHeader.writeUInt16LE(0, 30);
|
|
48
|
+
centralHeader.writeUInt16LE(0, 32);
|
|
49
|
+
centralHeader.writeUInt16LE(0, 34);
|
|
50
|
+
centralHeader.writeUInt16LE(0, 36);
|
|
51
|
+
centralHeader.writeUInt32LE(0, 38);
|
|
52
|
+
centralHeader.writeUInt32LE(0, 42);
|
|
53
|
+
const centralDirectoryOffset = localHeader.length + nameBytes.length + contentBytes.length;
|
|
54
|
+
const centralDirectorySize = centralHeader.length + nameBytes.length;
|
|
55
|
+
const endRecord = Buffer.alloc(22);
|
|
56
|
+
endRecord.writeUInt32LE(0x06054b50, 0);
|
|
57
|
+
endRecord.writeUInt16LE(0, 4);
|
|
58
|
+
endRecord.writeUInt16LE(0, 6);
|
|
59
|
+
endRecord.writeUInt16LE(1, 8);
|
|
60
|
+
endRecord.writeUInt16LE(1, 10);
|
|
61
|
+
endRecord.writeUInt32LE(centralDirectorySize, 12);
|
|
62
|
+
endRecord.writeUInt32LE(centralDirectoryOffset, 16);
|
|
63
|
+
endRecord.writeUInt16LE(0, 20);
|
|
64
|
+
await fs.writeFile(filePath, Buffer.concat([
|
|
65
|
+
localHeader,
|
|
66
|
+
nameBytes,
|
|
67
|
+
contentBytes,
|
|
68
|
+
centralHeader,
|
|
69
|
+
nameBytes,
|
|
70
|
+
endRecord
|
|
71
|
+
]));
|
|
72
|
+
}
|
|
6
73
|
describe('workspace file service', () => {
|
|
7
74
|
const tempRoot = path.join(os.tmpdir(), `aws-workspace-files-${Date.now()}`);
|
|
8
75
|
const workspacePath = path.join(tempRoot, 'workspace');
|
|
9
76
|
const nestedDir = path.join(workspacePath, 'src');
|
|
10
77
|
const nestedFile = path.join(nestedDir, 'index.ts');
|
|
78
|
+
let realWorkspacePath = '';
|
|
79
|
+
let realNestedFile = '';
|
|
11
80
|
beforeAll(async () => {
|
|
12
81
|
await fs.mkdir(nestedDir, { recursive: true });
|
|
13
82
|
await fs.writeFile(nestedFile, 'console.log("hello")\n', 'utf-8');
|
|
14
83
|
await fs.writeFile(path.join(workspacePath, 'README.md'), '# test\n', 'utf-8');
|
|
84
|
+
realWorkspacePath = (await fs.realpath(workspacePath)).replace(/\\/g, '/');
|
|
85
|
+
realNestedFile = (await fs.realpath(nestedFile)).replace(/\\/g, '/');
|
|
15
86
|
});
|
|
16
87
|
afterAll(async () => {
|
|
17
|
-
await
|
|
18
|
-
|
|
88
|
+
await Promise.all([
|
|
89
|
+
path.join(workspacePath, 'outside-secret-link.txt'),
|
|
90
|
+
path.join(workspacePath, 'outside-link'),
|
|
91
|
+
path.join(tempRoot, 'archive-link')
|
|
92
|
+
].map(async (linkPath) => {
|
|
93
|
+
try {
|
|
94
|
+
const stat = await fs.lstat(linkPath);
|
|
95
|
+
if (stat.isSymbolicLink()) {
|
|
96
|
+
await fs.unlink(linkPath);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Best-effort cleanup for platform-dependent symlink tests.
|
|
101
|
+
}
|
|
102
|
+
}));
|
|
103
|
+
await fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
104
|
+
}, 30_000);
|
|
19
105
|
it('lists workspace root when request path is empty', async () => {
|
|
20
106
|
const result = await listWorkspaceDirectory({
|
|
21
107
|
workspacePath,
|
|
22
108
|
targetPath: ''
|
|
23
109
|
});
|
|
24
|
-
expect(result.currentPath).toBe(
|
|
25
|
-
expect(result.workspacePath).toBe(
|
|
110
|
+
expect(result.currentPath).toBe(realWorkspacePath);
|
|
111
|
+
expect(result.workspacePath).toBe(realWorkspacePath);
|
|
26
112
|
expect(result.items.some((item) => item.name === 'src' && item.isDirectory)).toBe(true);
|
|
27
113
|
expect(result.items.some((item) => item.name === 'README.md' && !item.isDirectory)).toBe(true);
|
|
114
|
+
expect(result.items.find((item) => item.name === 'README.md')?.size).toBe(Buffer.byteLength('# test\n'));
|
|
28
115
|
expect(result.items.every((item) => !item.path.includes('\\'))).toBe(true);
|
|
29
116
|
});
|
|
30
117
|
it('rejects reading files outside workspace', async () => {
|
|
@@ -39,8 +126,8 @@ describe('workspace file service', () => {
|
|
|
39
126
|
filePath: nestedFile
|
|
40
127
|
});
|
|
41
128
|
expect(beforeRead.content).toBe('console.log("hello")\n');
|
|
42
|
-
expect(beforeRead.workspacePath).toBe(
|
|
43
|
-
expect(beforeRead.filePath).toBe(
|
|
129
|
+
expect(beforeRead.workspacePath).toBe(realWorkspacePath);
|
|
130
|
+
expect(beforeRead.filePath).toBe(realNestedFile);
|
|
44
131
|
await writeWorkspaceFile({
|
|
45
132
|
workspacePath,
|
|
46
133
|
filePath: nestedFile,
|
|
@@ -79,13 +166,13 @@ describe('workspace file service', () => {
|
|
|
79
166
|
targetPath: createdFile,
|
|
80
167
|
newName: 'demo-renamed.txt'
|
|
81
168
|
});
|
|
82
|
-
expect(renameFileResult.targetPath).toBe(renamedFile.replace(/\\/g, '/'));
|
|
169
|
+
expect(renameFileResult.targetPath).toBe((await fs.realpath(renamedFile)).replace(/\\/g, '/'));
|
|
83
170
|
const renameDirResult = await renameWorkspaceEntry({
|
|
84
171
|
workspacePath,
|
|
85
172
|
targetPath: featureDir,
|
|
86
173
|
newName: 'feature-renamed'
|
|
87
174
|
});
|
|
88
|
-
expect(renameDirResult.targetPath).toBe(renamedDir.replace(/\\/g, '/'));
|
|
175
|
+
expect(renameDirResult.targetPath).toBe((await fs.realpath(renamedDir)).replace(/\\/g, '/'));
|
|
89
176
|
const listRenamedDirectory = await listWorkspaceDirectory({
|
|
90
177
|
workspacePath,
|
|
91
178
|
targetPath: renamedDir
|
|
@@ -114,4 +201,293 @@ describe('workspace file service', () => {
|
|
|
114
201
|
targetPath: workspacePath
|
|
115
202
|
})).rejects.toThrow(/Workspace root cannot be modified/);
|
|
116
203
|
});
|
|
204
|
+
it('uploads files to a selected workspace directory', async () => {
|
|
205
|
+
const uploadSource = path.join(tempRoot, 'upload-source.txt');
|
|
206
|
+
const uploadTargetDir = path.join(workspacePath, 'uploads');
|
|
207
|
+
await fs.mkdir(uploadTargetDir, { recursive: true });
|
|
208
|
+
await fs.writeFile(uploadSource, 'uploaded content', 'utf-8');
|
|
209
|
+
const result = await uploadWorkspaceFiles({
|
|
210
|
+
workspacePath,
|
|
211
|
+
targetPath: uploadTargetDir,
|
|
212
|
+
files: [{ originalname: '../safe-name.txt', path: uploadSource, size: 16 }]
|
|
213
|
+
});
|
|
214
|
+
const uploadedPath = path.join(uploadTargetDir, 'safe-name.txt');
|
|
215
|
+
expect(result.files[0].targetPath).toBe((await fs.realpath(uploadedPath)).replace(/\\/g, '/'));
|
|
216
|
+
await expect(fs.readFile(uploadedPath, 'utf-8')).resolves.toBe('uploaded content');
|
|
217
|
+
});
|
|
218
|
+
it('preserves safe relative paths when uploading folders', async () => {
|
|
219
|
+
const firstSource = path.join(tempRoot, 'folder-upload-first.txt');
|
|
220
|
+
const secondSource = path.join(tempRoot, 'folder-upload-second.txt');
|
|
221
|
+
const uploadTargetDir = path.join(workspacePath, 'folder-uploads');
|
|
222
|
+
await fs.mkdir(uploadTargetDir, { recursive: true });
|
|
223
|
+
await fs.writeFile(firstSource, 'first nested file', 'utf-8');
|
|
224
|
+
await fs.writeFile(secondSource, 'second nested file', 'utf-8');
|
|
225
|
+
const result = await uploadWorkspaceFiles({
|
|
226
|
+
workspacePath,
|
|
227
|
+
targetPath: uploadTargetDir,
|
|
228
|
+
files: [
|
|
229
|
+
{ originalname: 'first.txt', relativePath: 'project/src/first.txt', path: firstSource, size: 17 },
|
|
230
|
+
{ originalname: 'second.txt', relativePath: 'project/docs/second.txt', path: secondSource, size: 18 }
|
|
231
|
+
]
|
|
232
|
+
});
|
|
233
|
+
const firstUploadedPath = path.join(uploadTargetDir, 'project', 'src', 'first.txt');
|
|
234
|
+
const secondUploadedPath = path.join(uploadTargetDir, 'project', 'docs', 'second.txt');
|
|
235
|
+
expect(result.files.map((file) => file.targetPath)).toEqual([
|
|
236
|
+
(await fs.realpath(firstUploadedPath)).replace(/\\/g, '/'),
|
|
237
|
+
(await fs.realpath(secondUploadedPath)).replace(/\\/g, '/')
|
|
238
|
+
]);
|
|
239
|
+
await expect(fs.readFile(firstUploadedPath, 'utf-8')).resolves.toBe('first nested file');
|
|
240
|
+
await expect(fs.readFile(secondUploadedPath, 'utf-8')).resolves.toBe('second nested file');
|
|
241
|
+
});
|
|
242
|
+
it('rejects unsafe relative upload paths', async () => {
|
|
243
|
+
const uploadSource = path.join(tempRoot, 'unsafe-folder-upload.txt');
|
|
244
|
+
const uploadTargetDir = path.join(workspacePath, 'unsafe-folder-uploads');
|
|
245
|
+
await fs.mkdir(uploadTargetDir, { recursive: true });
|
|
246
|
+
await fs.writeFile(uploadSource, 'unsafe content', 'utf-8');
|
|
247
|
+
await expect(uploadWorkspaceFiles({
|
|
248
|
+
workspacePath,
|
|
249
|
+
targetPath: uploadTargetDir,
|
|
250
|
+
files: [{ originalname: 'safe.txt', relativePath: 'project/../escape.txt', path: uploadSource, size: 14 }]
|
|
251
|
+
})).rejects.toThrow(/entryName is invalid/);
|
|
252
|
+
});
|
|
253
|
+
it('uploads binary exe files and lists them after upload', async () => {
|
|
254
|
+
const uploadSource = path.join(tempRoot, 'demo-app-source.exe');
|
|
255
|
+
const uploadTargetDir = path.join(workspacePath, 'bin');
|
|
256
|
+
const exeBytes = Buffer.from([0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00, 0xff, 0x00]);
|
|
257
|
+
await fs.mkdir(uploadTargetDir, { recursive: true });
|
|
258
|
+
await fs.writeFile(uploadSource, exeBytes);
|
|
259
|
+
const uploadResult = await uploadWorkspaceFiles({
|
|
260
|
+
workspacePath,
|
|
261
|
+
targetPath: uploadTargetDir,
|
|
262
|
+
files: [{ originalname: 'demo-app.exe', path: uploadSource, size: exeBytes.length }]
|
|
263
|
+
});
|
|
264
|
+
const uploadedPath = path.join(uploadTargetDir, 'demo-app.exe');
|
|
265
|
+
expect(uploadResult.files[0]).toMatchObject({
|
|
266
|
+
fileName: 'demo-app.exe',
|
|
267
|
+
size: exeBytes.length
|
|
268
|
+
});
|
|
269
|
+
await expect(fs.readFile(uploadedPath)).resolves.toEqual(exeBytes);
|
|
270
|
+
const listedDirectory = await listWorkspaceDirectory({
|
|
271
|
+
workspacePath,
|
|
272
|
+
targetPath: uploadTargetDir
|
|
273
|
+
});
|
|
274
|
+
expect(listedDirectory.items.some((item) => item.name === 'demo-app.exe' && !item.isDirectory)).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
it('decodes latin1-mojibake UTF-8 upload file names before saving', async () => {
|
|
277
|
+
const uploadSource = path.join(tempRoot, 'springboot-source.zip');
|
|
278
|
+
const uploadTargetDir = path.join(workspacePath, 'encoded-uploads');
|
|
279
|
+
const originalName = 'springboot测试压缩包2.zip';
|
|
280
|
+
const mojibakeName = Buffer.from(originalName, 'utf8').toString('latin1');
|
|
281
|
+
await fs.mkdir(uploadTargetDir, { recursive: true });
|
|
282
|
+
await fs.writeFile(uploadSource, 'zip bytes', 'utf-8');
|
|
283
|
+
const uploadResult = await uploadWorkspaceFiles({
|
|
284
|
+
workspacePath,
|
|
285
|
+
targetPath: uploadTargetDir,
|
|
286
|
+
files: [{ originalname: mojibakeName, path: uploadSource, size: 9 }]
|
|
287
|
+
});
|
|
288
|
+
const uploadedPath = path.join(uploadTargetDir, originalName);
|
|
289
|
+
expect(uploadResult.files[0].fileName).toBe(originalName);
|
|
290
|
+
await expect(fs.readFile(uploadedPath, 'utf-8')).resolves.toBe('zip bytes');
|
|
291
|
+
const listedDirectory = await listWorkspaceDirectory({
|
|
292
|
+
workspacePath,
|
|
293
|
+
targetPath: uploadTargetDir
|
|
294
|
+
});
|
|
295
|
+
expect(listedDirectory.items.some((item) => item.name === originalName)).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
it('extracts uploaded zip archives into a directory named after the archive', async () => {
|
|
298
|
+
const uploadTargetDir = path.join(workspacePath, 'zip-auto-extract');
|
|
299
|
+
const uploadSource = path.join(tempRoot, 'project-package-source.zip');
|
|
300
|
+
await fs.mkdir(uploadTargetDir, { recursive: true });
|
|
301
|
+
await new Promise((resolve, reject) => {
|
|
302
|
+
const archive = new ZipArchive();
|
|
303
|
+
const output = createWriteStream(uploadSource);
|
|
304
|
+
output.on('close', resolve);
|
|
305
|
+
archive.on('error', reject);
|
|
306
|
+
archive.pipe(output);
|
|
307
|
+
archive.append('first file', { name: 'a.txt' });
|
|
308
|
+
archive.append('second file', { name: 'nested/b.txt' });
|
|
309
|
+
archive.finalize().catch(reject);
|
|
310
|
+
});
|
|
311
|
+
const uploadResult = await uploadWorkspaceFiles({
|
|
312
|
+
workspacePath,
|
|
313
|
+
targetPath: uploadTargetDir,
|
|
314
|
+
extractArchives: true,
|
|
315
|
+
files: [{ originalname: 'project-package.zip', path: uploadSource, size: (await fs.stat(uploadSource)).size }]
|
|
316
|
+
});
|
|
317
|
+
const extractedRoot = path.join(uploadTargetDir, 'project-package');
|
|
318
|
+
expect(uploadResult.files[0].extracted?.targetPath).toBe((await fs.realpath(extractedRoot)).replace(/\\/g, '/'));
|
|
319
|
+
expect(uploadResult.files[0].extracted?.extractedEntries).toBe(2);
|
|
320
|
+
await expect(fs.readFile(path.join(extractedRoot, 'a.txt'), 'utf-8')).resolves.toBe('first file');
|
|
321
|
+
await expect(fs.readFile(path.join(extractedRoot, 'nested', 'b.txt'), 'utf-8')).resolves.toBe('second file');
|
|
322
|
+
await expect(fs.access(path.join(uploadTargetDir, 'a.txt'))).rejects.toThrow();
|
|
323
|
+
});
|
|
324
|
+
it('extracts uploaded tar archives into a directory named after the archive', async () => {
|
|
325
|
+
const uploadTargetDir = path.join(workspacePath, 'tar-auto-extract');
|
|
326
|
+
const tarSourceDir = path.join(tempRoot, 'tar-source');
|
|
327
|
+
const uploadSource = path.join(tempRoot, 'bundle-source.tar');
|
|
328
|
+
await fs.mkdir(path.join(tarSourceDir, 'nested'), { recursive: true });
|
|
329
|
+
await fs.mkdir(uploadTargetDir, { recursive: true });
|
|
330
|
+
await fs.writeFile(path.join(tarSourceDir, 'root.txt'), 'root tar file', 'utf-8');
|
|
331
|
+
await fs.writeFile(path.join(tarSourceDir, 'nested', 'child.txt'), 'child tar file', 'utf-8');
|
|
332
|
+
await tar.c({ file: uploadSource, cwd: tarSourceDir }, ['root.txt', 'nested/child.txt']);
|
|
333
|
+
const uploadResult = await uploadWorkspaceFiles({
|
|
334
|
+
workspacePath,
|
|
335
|
+
targetPath: uploadTargetDir,
|
|
336
|
+
extractArchives: true,
|
|
337
|
+
files: [{ originalname: 'bundle.tar', path: uploadSource, size: (await fs.stat(uploadSource)).size }]
|
|
338
|
+
});
|
|
339
|
+
const extractedRoot = path.join(uploadTargetDir, 'bundle');
|
|
340
|
+
expect(uploadResult.files[0].extracted?.targetPath).toBe((await fs.realpath(extractedRoot)).replace(/\\/g, '/'));
|
|
341
|
+
expect(uploadResult.files[0].extracted?.extractedEntries).toBe(2);
|
|
342
|
+
await expect(fs.readFile(path.join(extractedRoot, 'root.txt'), 'utf-8')).resolves.toBe('root tar file');
|
|
343
|
+
await expect(fs.readFile(path.join(extractedRoot, 'nested', 'child.txt'), 'utf-8')).resolves.toBe('child tar file');
|
|
344
|
+
await expect(fs.access(path.join(uploadTargetDir, 'root.txt'))).rejects.toThrow();
|
|
345
|
+
});
|
|
346
|
+
it('does not expose outside target size for file symlinks in directory listings', async () => {
|
|
347
|
+
const outsideFile = path.join(tempRoot, 'outside-secret.txt');
|
|
348
|
+
const symlinkPath = path.join(workspacePath, 'outside-secret-link.txt');
|
|
349
|
+
await fs.writeFile(outsideFile, 'outside-secret-content', 'utf-8');
|
|
350
|
+
try {
|
|
351
|
+
await fs.symlink(outsideFile, symlinkPath, 'file');
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
const errorCode = typeof error === 'object' && error !== null ? Reflect.get(error, 'code') : undefined;
|
|
355
|
+
if (errorCode === 'EPERM' || errorCode === 'EACCES') {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
const result = await listWorkspaceDirectory({
|
|
361
|
+
workspacePath,
|
|
362
|
+
targetPath: workspacePath
|
|
363
|
+
});
|
|
364
|
+
const symlinkItem = result.items.find((item) => item.name === 'outside-secret-link.txt');
|
|
365
|
+
expect(symlinkItem).toBeDefined();
|
|
366
|
+
expect(symlinkItem?.size).toBeUndefined();
|
|
367
|
+
});
|
|
368
|
+
it('resolves file and directory download targets safely', async () => {
|
|
369
|
+
const fileTarget = await resolveWorkspaceDownloadTarget({
|
|
370
|
+
workspacePath,
|
|
371
|
+
targetPath: nestedFile
|
|
372
|
+
});
|
|
373
|
+
expect(fileTarget.isDirectory).toBe(false);
|
|
374
|
+
expect(fileTarget.fileName).toBe('index.ts');
|
|
375
|
+
const directoryTarget = await resolveWorkspaceDownloadTarget({
|
|
376
|
+
workspacePath,
|
|
377
|
+
targetPath: nestedDir
|
|
378
|
+
});
|
|
379
|
+
expect(directoryTarget.isDirectory).toBe(true);
|
|
380
|
+
expect(directoryTarget.fileName).toBe('src.zip');
|
|
381
|
+
});
|
|
382
|
+
it('streams a workspace directory as a zip archive', async () => {
|
|
383
|
+
const zipPath = path.join(tempRoot, 'src.zip');
|
|
384
|
+
await streamWorkspaceDirectoryZip(nestedDir, createWriteStream(zipPath));
|
|
385
|
+
const stat = await fs.stat(zipPath);
|
|
386
|
+
expect(stat.size).toBeGreaterThan(0);
|
|
387
|
+
});
|
|
388
|
+
it('extracts zip archives and rejects path traversal entries', async () => {
|
|
389
|
+
const goodZip = path.join(workspacePath, 'archive.zip');
|
|
390
|
+
const badZip = path.join(workspacePath, 'bad.zip');
|
|
391
|
+
const extractDir = path.join(workspacePath, 'extracted');
|
|
392
|
+
await new Promise((resolve, reject) => {
|
|
393
|
+
const archive = new ZipArchive();
|
|
394
|
+
const output = createWriteStream(goodZip);
|
|
395
|
+
output.on('close', resolve);
|
|
396
|
+
archive.on('error', reject);
|
|
397
|
+
archive.pipe(output);
|
|
398
|
+
archive.append('zip content', { name: 'nested/file.txt' });
|
|
399
|
+
archive.finalize().catch(reject);
|
|
400
|
+
});
|
|
401
|
+
const extracted = await extractWorkspaceArchive({
|
|
402
|
+
workspacePath,
|
|
403
|
+
archivePath: goodZip,
|
|
404
|
+
outputPath: extractDir
|
|
405
|
+
});
|
|
406
|
+
expect(extracted.extractedEntries).toBe(1);
|
|
407
|
+
await expect(fs.readFile(path.join(extractDir, 'nested', 'file.txt'), 'utf-8')).resolves.toBe('zip content');
|
|
408
|
+
await writeStoredZip(badZip, '../escape.txt', 'escape');
|
|
409
|
+
await expect(extractWorkspaceArchive({
|
|
410
|
+
workspacePath,
|
|
411
|
+
archivePath: badZip,
|
|
412
|
+
outputPath: extractDir
|
|
413
|
+
})).rejects.toThrow(/Archive entry path is invalid|outside workspace/);
|
|
414
|
+
});
|
|
415
|
+
it('extracts GBK encoded zip entry names as Chinese paths', async () => {
|
|
416
|
+
const gbkZip = path.join(workspacePath, 'gbk-archive.zip');
|
|
417
|
+
const extractDir = path.join(workspacePath, 'gbk-extracted');
|
|
418
|
+
const chineseDirectoryName = 'springboot067摄影师社区2';
|
|
419
|
+
const gbkEntryNameBytes = Buffer.from([
|
|
420
|
+
115, 112, 114, 105, 110, 103, 98, 111, 111, 116, 48, 54, 55,
|
|
421
|
+
201, 227, 211, 176, 202, 166, 201, 231, 199, 248, 50,
|
|
422
|
+
47, 114, 101, 97, 100, 109, 101, 46, 116, 120, 116
|
|
423
|
+
]);
|
|
424
|
+
await writeStoredZipWithNameBytes(gbkZip, gbkEntryNameBytes, '中文目录内容');
|
|
425
|
+
const extracted = await extractWorkspaceArchive({
|
|
426
|
+
workspacePath,
|
|
427
|
+
archivePath: gbkZip,
|
|
428
|
+
outputPath: extractDir
|
|
429
|
+
});
|
|
430
|
+
expect(extracted.extractedEntries).toBe(1);
|
|
431
|
+
await expect(fs.readFile(path.join(extractDir, chineseDirectoryName, 'readme.txt'), 'utf-8'))
|
|
432
|
+
.resolves.toBe('中文目录内容');
|
|
433
|
+
});
|
|
434
|
+
it('returns docx preview metadata without parsing document contents', async () => {
|
|
435
|
+
const docxPath = path.join(workspacePath, 'large-preview.docx');
|
|
436
|
+
const largeImageLikePayload = Buffer.alloc(21 * 1024 * 1024, 1);
|
|
437
|
+
await fs.writeFile(docxPath, largeImageLikePayload);
|
|
438
|
+
const preview = await previewWorkspaceDocument({ workspacePath, filePath: docxPath });
|
|
439
|
+
expect(preview.kind).toBe('html');
|
|
440
|
+
expect(preview.content).toBe('');
|
|
441
|
+
expect(preview.size).toBe(largeImageLikePayload.length);
|
|
442
|
+
expect(preview.mtimeMs).toBeGreaterThan(0);
|
|
443
|
+
});
|
|
444
|
+
it('returns unsupported preview metadata for legacy doc files', async () => {
|
|
445
|
+
const docPath = path.join(workspacePath, 'legacy.doc');
|
|
446
|
+
await fs.writeFile(docPath, 'legacy word bytes', 'utf-8');
|
|
447
|
+
const preview = await previewWorkspaceDocument({ workspacePath, filePath: docPath });
|
|
448
|
+
expect(preview.kind).toBe('unsupported');
|
|
449
|
+
expect(preview.message).toMatch(/暂不支持旧版 \.doc/);
|
|
450
|
+
});
|
|
451
|
+
it('rejects workspace access through symlink escapes', async () => {
|
|
452
|
+
const outsideDir = path.join(tempRoot, 'outside');
|
|
453
|
+
const symlinkPath = path.join(workspacePath, 'outside-link');
|
|
454
|
+
await fs.mkdir(outsideDir, { recursive: true });
|
|
455
|
+
await fs.writeFile(path.join(outsideDir, 'secret.txt'), 'secret', 'utf-8');
|
|
456
|
+
try {
|
|
457
|
+
await fs.symlink(outsideDir, symlinkPath, 'dir');
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
const errorCode = typeof error === 'object' && error !== null ? Reflect.get(error, 'code') : undefined;
|
|
461
|
+
if (errorCode === 'EPERM') {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
await expect(readWorkspaceFile({
|
|
467
|
+
workspacePath,
|
|
468
|
+
filePath: path.join(symlinkPath, 'secret.txt')
|
|
469
|
+
})).rejects.toThrow(/outside workspace/);
|
|
470
|
+
});
|
|
471
|
+
it('rejects tar archives with symbolic links', async () => {
|
|
472
|
+
const archivePath = path.join(workspacePath, 'link.tar');
|
|
473
|
+
const linkTarget = path.join(tempRoot, 'linked-target');
|
|
474
|
+
const sourceLink = path.join(tempRoot, 'archive-link');
|
|
475
|
+
await fs.mkdir(linkTarget, { recursive: true });
|
|
476
|
+
try {
|
|
477
|
+
await fs.symlink(linkTarget, sourceLink, 'dir');
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
const errorCode = typeof error === 'object' && error !== null ? Reflect.get(error, 'code') : undefined;
|
|
481
|
+
if (errorCode === 'EPERM') {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
await tar.c({ file: archivePath, cwd: tempRoot }, ['archive-link']);
|
|
487
|
+
await expect(extractWorkspaceArchive({
|
|
488
|
+
workspacePath,
|
|
489
|
+
archivePath,
|
|
490
|
+
outputPath: workspacePath
|
|
491
|
+
})).rejects.toThrow(/links are not supported/);
|
|
492
|
+
});
|
|
117
493
|
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
interface WorkspaceFileWatchParams {
|
|
2
|
+
workspacePath: string;
|
|
3
|
+
filePath: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* 开始监听工作区内单个文件,重复监听同一文件会复用已有 watcher。
|
|
7
|
+
*/
|
|
8
|
+
export declare function watchWorkspaceFile(params: WorkspaceFileWatchParams): Promise<{
|
|
9
|
+
ok: true;
|
|
10
|
+
workspacePath: string;
|
|
11
|
+
filePath: string;
|
|
12
|
+
}>;
|
|
13
|
+
/**
|
|
14
|
+
* 停止监听工作区内单个文件;未监听时保持幂等。
|
|
15
|
+
*/
|
|
16
|
+
export declare function unwatchWorkspaceFile(params: WorkspaceFileWatchParams): Promise<{
|
|
17
|
+
ok: true;
|
|
18
|
+
}>;
|
|
19
|
+
export declare function getWorkspaceWatchCountForTests(): number;
|
|
20
|
+
export {};
|
|
21
|
+
//# sourceMappingURL=workspace-watch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-watch.d.ts","sourceRoot":"","sources":["../../src/services/workspace-watch.ts"],"names":[],"mappings":"AAKA,UAAU,wBAAwB;IAChC,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAmGD;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,wBAAwB,GAAG,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAmBzI;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,wBAAwB,GAAG,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,CAAC,CAUlG;AAED,wBAAgB,8BAA8B,IAAI,MAAM,CAEvD"}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { watch, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { sendFileChanged } from './session-output.js';
|
|
4
|
+
const watchedFiles = new Map();
|
|
5
|
+
const WATCH_DEBOUNCE_MS = 300;
|
|
6
|
+
function toDisplayPath(targetPath) {
|
|
7
|
+
return String(targetPath || '').replace(/\\/g, '/');
|
|
8
|
+
}
|
|
9
|
+
function ensureInsideWorkspace(workspaceRoot, targetPath) {
|
|
10
|
+
const resolvedTargetPath = path.resolve(targetPath);
|
|
11
|
+
const relativePath = path.relative(workspaceRoot, resolvedTargetPath);
|
|
12
|
+
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
13
|
+
throw new Error(`Target path is outside workspace: ${resolvedTargetPath}`);
|
|
14
|
+
}
|
|
15
|
+
return resolvedTargetPath;
|
|
16
|
+
}
|
|
17
|
+
async function resolveWatchTarget(params) {
|
|
18
|
+
const requestedWorkspaceRoot = path.resolve(String(params.workspacePath || '').trim());
|
|
19
|
+
const workspaceRoot = await fs.realpath(requestedWorkspaceRoot);
|
|
20
|
+
const requestedFilePath = path.resolve(String(params.filePath || '').trim());
|
|
21
|
+
const requestedRelativePath = path.relative(requestedWorkspaceRoot, requestedFilePath);
|
|
22
|
+
if (requestedRelativePath.startsWith('..') || path.isAbsolute(requestedRelativePath)) {
|
|
23
|
+
throw new Error(`Target path is outside workspace: ${requestedFilePath}`);
|
|
24
|
+
}
|
|
25
|
+
const filePath = ensureInsideWorkspace(workspaceRoot, path.join(workspaceRoot, requestedRelativePath));
|
|
26
|
+
const realFilePath = await fs.realpath(filePath);
|
|
27
|
+
ensureInsideWorkspace(workspaceRoot, realFilePath);
|
|
28
|
+
const stat = await fs.stat(realFilePath);
|
|
29
|
+
if (!stat.isFile()) {
|
|
30
|
+
throw new Error(`Path is not a file: ${realFilePath}`);
|
|
31
|
+
}
|
|
32
|
+
return { workspaceRoot, filePath: realFilePath };
|
|
33
|
+
}
|
|
34
|
+
function watchKey(workspaceRoot, filePath) {
|
|
35
|
+
return `${toDisplayPath(workspaceRoot)}::${toDisplayPath(filePath)}`;
|
|
36
|
+
}
|
|
37
|
+
function closeWatchRecord(key, record) {
|
|
38
|
+
if (record.timer) {
|
|
39
|
+
clearTimeout(record.timer);
|
|
40
|
+
record.timer = null;
|
|
41
|
+
}
|
|
42
|
+
record.watcher.close();
|
|
43
|
+
watchedFiles.delete(key);
|
|
44
|
+
}
|
|
45
|
+
async function resolveWatchKey(params) {
|
|
46
|
+
const requestedWorkspaceRoot = path.resolve(String(params.workspacePath || '').trim());
|
|
47
|
+
const workspaceRoot = await fs.realpath(requestedWorkspaceRoot);
|
|
48
|
+
const requestedFilePath = path.resolve(String(params.filePath || '').trim());
|
|
49
|
+
const requestedRelativePath = path.relative(requestedWorkspaceRoot, requestedFilePath);
|
|
50
|
+
if (requestedRelativePath.startsWith('..') || path.isAbsolute(requestedRelativePath)) {
|
|
51
|
+
throw new Error(`Target path is outside workspace: ${requestedFilePath}`);
|
|
52
|
+
}
|
|
53
|
+
const filePath = ensureInsideWorkspace(workspaceRoot, path.join(workspaceRoot, requestedRelativePath));
|
|
54
|
+
return watchKey(workspaceRoot, filePath);
|
|
55
|
+
}
|
|
56
|
+
async function notifyFileChanged(record, eventType) {
|
|
57
|
+
let size;
|
|
58
|
+
let mtimeMs;
|
|
59
|
+
try {
|
|
60
|
+
const stat = await fs.stat(record.filePath);
|
|
61
|
+
size = stat.size;
|
|
62
|
+
mtimeMs = stat.mtimeMs;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Deletions or transient writes still notify the frontend so it can show a refresh error if needed.
|
|
66
|
+
}
|
|
67
|
+
await sendFileChanged({
|
|
68
|
+
workspacePath: toDisplayPath(record.workspacePath),
|
|
69
|
+
filePath: toDisplayPath(record.filePath),
|
|
70
|
+
eventType,
|
|
71
|
+
size,
|
|
72
|
+
mtimeMs,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function scheduleFileChanged(record, eventType) {
|
|
76
|
+
if (record.timer) {
|
|
77
|
+
clearTimeout(record.timer);
|
|
78
|
+
}
|
|
79
|
+
record.timer = setTimeout(() => {
|
|
80
|
+
record.timer = null;
|
|
81
|
+
notifyFileChanged(record, eventType).catch(() => undefined);
|
|
82
|
+
}, WATCH_DEBOUNCE_MS);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 开始监听工作区内单个文件,重复监听同一文件会复用已有 watcher。
|
|
86
|
+
*/
|
|
87
|
+
export async function watchWorkspaceFile(params) {
|
|
88
|
+
const { workspaceRoot, filePath } = await resolveWatchTarget(params);
|
|
89
|
+
const key = watchKey(workspaceRoot, filePath);
|
|
90
|
+
const existingRecord = watchedFiles.get(key);
|
|
91
|
+
if (existingRecord) {
|
|
92
|
+
existingRecord.refCount += 1;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const record = {
|
|
96
|
+
watcher: watch(filePath, (eventType) => scheduleFileChanged(record, String(eventType || 'change'))),
|
|
97
|
+
workspacePath: workspaceRoot,
|
|
98
|
+
filePath,
|
|
99
|
+
timer: null,
|
|
100
|
+
refCount: 1,
|
|
101
|
+
};
|
|
102
|
+
record.watcher.on('error', () => closeWatchRecord(key, record));
|
|
103
|
+
watchedFiles.set(key, record);
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, workspacePath: toDisplayPath(workspaceRoot), filePath: toDisplayPath(filePath) };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 停止监听工作区内单个文件;未监听时保持幂等。
|
|
109
|
+
*/
|
|
110
|
+
export async function unwatchWorkspaceFile(params) {
|
|
111
|
+
const key = await resolveWatchKey(params);
|
|
112
|
+
const record = watchedFiles.get(key);
|
|
113
|
+
if (record) {
|
|
114
|
+
record.refCount -= 1;
|
|
115
|
+
if (record.refCount <= 0) {
|
|
116
|
+
closeWatchRecord(key, record);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { ok: true };
|
|
120
|
+
}
|
|
121
|
+
export function getWorkspaceWatchCountForTests() {
|
|
122
|
+
return watchedFiles.size;
|
|
123
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-watch.test.d.ts","sourceRoot":"","sources":["../../src/services/workspace-watch.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { getWorkspaceWatchCountForTests, unwatchWorkspaceFile, watchWorkspaceFile, } from './workspace-watch.js';
|
|
6
|
+
describe('workspace file watch service', () => {
|
|
7
|
+
let tempRoot = '';
|
|
8
|
+
let workspacePath = '';
|
|
9
|
+
let filePath = '';
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aws-workspace-watch-'));
|
|
12
|
+
workspacePath = path.join(tempRoot, 'workspace');
|
|
13
|
+
filePath = path.join(workspacePath, 'demo.docx');
|
|
14
|
+
await fs.mkdir(workspacePath, { recursive: true });
|
|
15
|
+
await fs.writeFile(filePath, 'watch me', 'utf-8');
|
|
16
|
+
});
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await unwatchWorkspaceFile({ workspacePath, filePath }).catch(() => undefined);
|
|
19
|
+
await unwatchWorkspaceFile({ workspacePath, filePath }).catch(() => undefined);
|
|
20
|
+
await fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
21
|
+
});
|
|
22
|
+
it('unwatches a deleted file by its original workspace path key', async () => {
|
|
23
|
+
await watchWorkspaceFile({ workspacePath, filePath });
|
|
24
|
+
expect(getWorkspaceWatchCountForTests()).toBe(1);
|
|
25
|
+
await fs.rm(filePath);
|
|
26
|
+
await unwatchWorkspaceFile({ workspacePath, filePath });
|
|
27
|
+
expect(getWorkspaceWatchCountForTests()).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
it('keeps shared watchers until all references unwatch', async () => {
|
|
30
|
+
await watchWorkspaceFile({ workspacePath, filePath });
|
|
31
|
+
await watchWorkspaceFile({ workspacePath, filePath });
|
|
32
|
+
expect(getWorkspaceWatchCountForTests()).toBe(1);
|
|
33
|
+
await unwatchWorkspaceFile({ workspacePath, filePath });
|
|
34
|
+
expect(getWorkspaceWatchCountForTests()).toBe(1);
|
|
35
|
+
await unwatchWorkspaceFile({ workspacePath, filePath });
|
|
36
|
+
expect(getWorkspaceWatchCountForTests()).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
});
|