aws-runtime-bridge 1.4.0 → 1.6.1

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.
Files changed (72) hide show
  1. package/README.md +1 -1
  2. package/dist/adapter/AdapterRegistry.d.ts +1 -1
  3. package/dist/adapter/AdapterRegistry.d.ts.map +1 -1
  4. package/dist/adapter/AdapterRegistry.js +0 -2
  5. package/dist/adapter/ClaudeSdkAdapter.d.ts +4 -0
  6. package/dist/adapter/ClaudeSdkAdapter.d.ts.map +1 -1
  7. package/dist/adapter/ClaudeSdkAdapter.js +11 -2
  8. package/dist/adapter/CodexSdkAdapter.js +1 -1
  9. package/dist/adapter/OpencodeSdkAdapter.d.ts +13 -1
  10. package/dist/adapter/OpencodeSdkAdapter.d.ts.map +1 -1
  11. package/dist/adapter/OpencodeSdkAdapter.js +58 -6
  12. package/dist/adapter/OpencodeSdkAdapter.test.js +57 -1
  13. package/dist/adapter/types.d.ts +10 -0
  14. package/dist/adapter/types.d.ts.map +1 -1
  15. package/dist/index.js +14 -43
  16. package/dist/middleware/auth.d.ts +5 -0
  17. package/dist/middleware/auth.d.ts.map +1 -1
  18. package/dist/middleware/auth.js +9 -1
  19. package/dist/routes/file-browser.d.ts +10 -0
  20. package/dist/routes/file-browser.d.ts.map +1 -1
  21. package/dist/routes/file-browser.js +226 -4
  22. package/dist/routes/file-browser.test.js +31 -0
  23. package/dist/routes/instance.d.ts +10 -0
  24. package/dist/routes/instance.d.ts.map +1 -1
  25. package/dist/routes/instance.js +93 -2
  26. package/dist/routes/instance.test.js +50 -0
  27. package/dist/routes/pty.d.ts +106 -0
  28. package/dist/routes/pty.d.ts.map +1 -0
  29. package/dist/routes/pty.js +526 -0
  30. package/dist/routes/pty.test.d.ts +2 -0
  31. package/dist/routes/pty.test.d.ts.map +1 -0
  32. package/dist/routes/pty.test.js +73 -0
  33. package/dist/routes/sessions.d.ts +1 -1
  34. package/dist/routes/sessions.d.ts.map +1 -1
  35. package/dist/routes/sessions.js +32 -213
  36. package/dist/routes/terminal.d.ts +32 -3
  37. package/dist/routes/terminal.d.ts.map +1 -1
  38. package/dist/routes/terminal.js +411 -243
  39. package/dist/routes/terminal.test.js +105 -29
  40. package/dist/services/agent-process-manager.d.ts +2 -2
  41. package/dist/services/agent-process-manager.d.ts.map +1 -1
  42. package/dist/services/agent-process-manager.js +3 -3
  43. package/dist/services/process-detector.d.ts +2 -4
  44. package/dist/services/process-detector.d.ts.map +1 -1
  45. package/dist/services/process-detector.js +9 -16
  46. package/dist/services/process-registry.d.ts +2 -2
  47. package/dist/services/process-registry.d.ts.map +1 -1
  48. package/dist/services/process-registry.js +1 -1
  49. package/dist/services/session-output.d.ts +27 -5
  50. package/dist/services/session-output.d.ts.map +1 -1
  51. package/dist/services/session-output.js +48 -3
  52. package/dist/services/session-output.test.js +43 -29
  53. package/dist/services/terminal-persistence.d.ts +9 -0
  54. package/dist/services/terminal-persistence.d.ts.map +1 -1
  55. package/dist/services/terminal-persistence.js +20 -0
  56. package/dist/services/tool-installer.d.ts +10 -0
  57. package/dist/services/tool-installer.d.ts.map +1 -1
  58. package/dist/services/tool-installer.js +126 -5
  59. package/dist/services/tool-installer.test.js +32 -1
  60. package/dist/services/workspace-files.d.ts +86 -0
  61. package/dist/services/workspace-files.d.ts.map +1 -1
  62. package/dist/services/workspace-files.js +571 -21
  63. package/dist/services/workspace-files.test.js +471 -11
  64. package/dist/services/workspace-watch.d.ts +21 -0
  65. package/dist/services/workspace-watch.d.ts.map +1 -0
  66. package/dist/services/workspace-watch.js +123 -0
  67. package/dist/services/workspace-watch.test.d.ts +2 -0
  68. package/dist/services/workspace-watch.test.d.ts.map +1 -0
  69. package/dist/services/workspace-watch.test.js +38 -0
  70. package/dist/types.d.ts +8 -4
  71. package/dist/types.d.ts.map +1 -1
  72. package/package.json +9 -1
@@ -1,30 +1,138 @@
1
- import { describe, it, expect, beforeAll, afterAll } from 'vitest';
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 { promises as fs } from 'node:fs';
5
- import { createWorkspaceEntry, deleteWorkspaceEntry, listWorkspaceDirectory, readWorkspaceFile, renameWorkspaceEntry, writeWorkspaceFile, } from './workspace-files.js';
4
+ import * as tar from 'tar';
5
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
6
+ import { createWorkspaceEntry, deleteWorkspaceEntry, extractWorkspaceArchive, listWorkspaceDirectory, moveWorkspaceEntry, 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
+ }
73
+ async function writeTarHeaderOnlyEntry(filePath, entryName, typeFlag) {
74
+ const header = Buffer.alloc(512, 0);
75
+ header.write(entryName, 0, Math.min(Buffer.byteLength(entryName), 100), 'utf-8');
76
+ header.write('0000644\0', 100, 'ascii');
77
+ header.write('0000000\0', 108, 'ascii');
78
+ header.write('0000000\0', 116, 'ascii');
79
+ header.write('00000000000\0', 124, 'ascii');
80
+ header.write('00000000000\0', 136, 'ascii');
81
+ header.fill(0x20, 148, 156);
82
+ header.write(typeFlag, 156, 'ascii');
83
+ header.write('ustar\0', 257, 'ascii');
84
+ header.write('00', 263, 'ascii');
85
+ let checksum = 0;
86
+ for (const byte of header) {
87
+ checksum += byte;
88
+ }
89
+ header.write(checksum.toString(8).padStart(6, '0'), 148, 'ascii');
90
+ header[154] = 0;
91
+ header[155] = 0x20;
92
+ await fs.writeFile(filePath, Buffer.concat([header, Buffer.alloc(1024, 0)]));
93
+ }
6
94
  describe('workspace file service', () => {
7
95
  const tempRoot = path.join(os.tmpdir(), `aws-workspace-files-${Date.now()}`);
8
96
  const workspacePath = path.join(tempRoot, 'workspace');
9
97
  const nestedDir = path.join(workspacePath, 'src');
10
98
  const nestedFile = path.join(nestedDir, 'index.ts');
99
+ let realWorkspacePath = '';
100
+ let realNestedFile = '';
11
101
  beforeAll(async () => {
12
102
  await fs.mkdir(nestedDir, { recursive: true });
13
103
  await fs.writeFile(nestedFile, 'console.log("hello")\n', 'utf-8');
14
104
  await fs.writeFile(path.join(workspacePath, 'README.md'), '# test\n', 'utf-8');
105
+ realWorkspacePath = (await fs.realpath(workspacePath)).replace(/\\/g, '/');
106
+ realNestedFile = (await fs.realpath(nestedFile)).replace(/\\/g, '/');
15
107
  });
16
108
  afterAll(async () => {
17
- await fs.rm(tempRoot, { recursive: true, force: true });
18
- });
109
+ await Promise.all([
110
+ path.join(workspacePath, 'outside-secret-link.txt'),
111
+ path.join(workspacePath, 'outside-link'),
112
+ path.join(tempRoot, 'archive-link')
113
+ ].map(async (linkPath) => {
114
+ try {
115
+ const stat = await fs.lstat(linkPath);
116
+ if (stat.isSymbolicLink()) {
117
+ await fs.unlink(linkPath);
118
+ }
119
+ }
120
+ catch {
121
+ // Best-effort cleanup for platform-dependent symlink tests.
122
+ }
123
+ }));
124
+ await fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
125
+ }, 30_000);
19
126
  it('lists workspace root when request path is empty', async () => {
20
127
  const result = await listWorkspaceDirectory({
21
128
  workspacePath,
22
129
  targetPath: ''
23
130
  });
24
- expect(result.currentPath).toBe(workspacePath.replace(/\\/g, '/'));
25
- expect(result.workspacePath).toBe(workspacePath.replace(/\\/g, '/'));
131
+ expect(result.currentPath).toBe(realWorkspacePath);
132
+ expect(result.workspacePath).toBe(realWorkspacePath);
26
133
  expect(result.items.some((item) => item.name === 'src' && item.isDirectory)).toBe(true);
27
134
  expect(result.items.some((item) => item.name === 'README.md' && !item.isDirectory)).toBe(true);
135
+ expect(result.items.find((item) => item.name === 'README.md')?.size).toBe(Buffer.byteLength('# test\n'));
28
136
  expect(result.items.every((item) => !item.path.includes('\\'))).toBe(true);
29
137
  });
30
138
  it('rejects reading files outside workspace', async () => {
@@ -39,8 +147,8 @@ describe('workspace file service', () => {
39
147
  filePath: nestedFile
40
148
  });
41
149
  expect(beforeRead.content).toBe('console.log("hello")\n');
42
- expect(beforeRead.workspacePath).toBe(workspacePath.replace(/\\/g, '/'));
43
- expect(beforeRead.filePath).toBe(nestedFile.replace(/\\/g, '/'));
150
+ expect(beforeRead.workspacePath).toBe(realWorkspacePath);
151
+ expect(beforeRead.filePath).toBe(realNestedFile);
44
152
  await writeWorkspaceFile({
45
153
  workspacePath,
46
154
  filePath: nestedFile,
@@ -79,13 +187,13 @@ describe('workspace file service', () => {
79
187
  targetPath: createdFile,
80
188
  newName: 'demo-renamed.txt'
81
189
  });
82
- expect(renameFileResult.targetPath).toBe(renamedFile.replace(/\\/g, '/'));
190
+ expect(renameFileResult.targetPath).toBe((await fs.realpath(renamedFile)).replace(/\\/g, '/'));
83
191
  const renameDirResult = await renameWorkspaceEntry({
84
192
  workspacePath,
85
193
  targetPath: featureDir,
86
194
  newName: 'feature-renamed'
87
195
  });
88
- expect(renameDirResult.targetPath).toBe(renamedDir.replace(/\\/g, '/'));
196
+ expect(renameDirResult.targetPath).toBe((await fs.realpath(renamedDir)).replace(/\\/g, '/'));
89
197
  const listRenamedDirectory = await listWorkspaceDirectory({
90
198
  workspacePath,
91
199
  targetPath: renamedDir
@@ -114,4 +222,356 @@ describe('workspace file service', () => {
114
222
  targetPath: workspacePath
115
223
  })).rejects.toThrow(/Workspace root cannot be modified/);
116
224
  });
225
+ it('moves files and rejects moving directories into themselves', async () => {
226
+ const moveSourceDir = path.join(workspacePath, 'move-source');
227
+ const moveDestinationDir = path.join(workspacePath, 'move-destination');
228
+ const fileToMove = path.join(moveSourceDir, 'move-me.txt');
229
+ await fs.mkdir(moveSourceDir, { recursive: true });
230
+ await fs.mkdir(moveDestinationDir, { recursive: true });
231
+ await fs.writeFile(fileToMove, 'move me', 'utf-8');
232
+ const result = await moveWorkspaceEntry({
233
+ workspacePath,
234
+ targetPath: fileToMove,
235
+ destinationPath: moveDestinationDir,
236
+ });
237
+ const movedPath = path.join(moveDestinationDir, 'move-me.txt');
238
+ expect(result.sourcePath).toBe(fileToMove.replace(/\\/g, '/'));
239
+ expect(result.targetPath).toBe((await fs.realpath(movedPath)).replace(/\\/g, '/'));
240
+ await expect(fs.access(fileToMove)).rejects.toThrow();
241
+ await expect(fs.readFile(movedPath, 'utf-8')).resolves.toBe('move me');
242
+ const childDestination = path.join(moveSourceDir, 'child');
243
+ await fs.mkdir(childDestination, { recursive: true });
244
+ await expect(moveWorkspaceEntry({
245
+ workspacePath,
246
+ targetPath: moveSourceDir,
247
+ destinationPath: childDestination,
248
+ })).rejects.toThrow(/itself or its descendant/);
249
+ });
250
+ it('rejects unsafe workspace move targets', async () => {
251
+ const moveSafetyDir = path.join(workspacePath, 'move-safety');
252
+ const moveSafetyFile = path.join(moveSafetyDir, 'safe.txt');
253
+ const moveSafetyDestination = path.join(workspacePath, 'move-safety-dest');
254
+ await fs.mkdir(moveSafetyDir, { recursive: true });
255
+ await fs.mkdir(moveSafetyDestination, { recursive: true });
256
+ await fs.writeFile(moveSafetyFile, 'safe', 'utf-8');
257
+ await fs.writeFile(path.join(moveSafetyDestination, 'safe.txt'), 'exists', 'utf-8');
258
+ await expect(moveWorkspaceEntry({
259
+ workspacePath,
260
+ targetPath: workspacePath,
261
+ destinationPath: moveSafetyDestination,
262
+ })).rejects.toThrow(/Workspace root cannot be modified/);
263
+ await expect(moveWorkspaceEntry({
264
+ workspacePath,
265
+ targetPath: path.join(tempRoot, 'outside.txt'),
266
+ destinationPath: moveSafetyDestination,
267
+ })).rejects.toThrow(/outside workspace/);
268
+ await expect(moveWorkspaceEntry({
269
+ workspacePath,
270
+ targetPath: moveSafetyFile,
271
+ destinationPath: tempRoot,
272
+ })).rejects.toThrow(/outside workspace/);
273
+ await expect(moveWorkspaceEntry({
274
+ workspacePath,
275
+ targetPath: moveSafetyFile,
276
+ destinationPath: moveSafetyDestination,
277
+ })).rejects.toThrow(/Path already exists/);
278
+ });
279
+ it('uploads files to a selected workspace directory', async () => {
280
+ const uploadSource = path.join(tempRoot, 'upload-source.txt');
281
+ const uploadTargetDir = path.join(workspacePath, 'uploads');
282
+ await fs.mkdir(uploadTargetDir, { recursive: true });
283
+ await fs.writeFile(uploadSource, 'uploaded content', 'utf-8');
284
+ const result = await uploadWorkspaceFiles({
285
+ workspacePath,
286
+ targetPath: uploadTargetDir,
287
+ files: [{ originalname: '../safe-name.txt', path: uploadSource, size: 16 }]
288
+ });
289
+ const uploadedPath = path.join(uploadTargetDir, 'safe-name.txt');
290
+ expect(result.files[0].targetPath).toBe((await fs.realpath(uploadedPath)).replace(/\\/g, '/'));
291
+ await expect(fs.readFile(uploadedPath, 'utf-8')).resolves.toBe('uploaded content');
292
+ });
293
+ it('preserves safe relative paths when uploading folders', async () => {
294
+ const firstSource = path.join(tempRoot, 'folder-upload-first.txt');
295
+ const secondSource = path.join(tempRoot, 'folder-upload-second.txt');
296
+ const uploadTargetDir = path.join(workspacePath, 'folder-uploads');
297
+ await fs.mkdir(uploadTargetDir, { recursive: true });
298
+ await fs.writeFile(firstSource, 'first nested file', 'utf-8');
299
+ await fs.writeFile(secondSource, 'second nested file', 'utf-8');
300
+ const result = await uploadWorkspaceFiles({
301
+ workspacePath,
302
+ targetPath: uploadTargetDir,
303
+ files: [
304
+ { originalname: 'first.txt', relativePath: 'project/src/first.txt', path: firstSource, size: 17 },
305
+ { originalname: 'second.txt', relativePath: 'project/docs/second.txt', path: secondSource, size: 18 }
306
+ ]
307
+ });
308
+ const firstUploadedPath = path.join(uploadTargetDir, 'project', 'src', 'first.txt');
309
+ const secondUploadedPath = path.join(uploadTargetDir, 'project', 'docs', 'second.txt');
310
+ expect(result.files.map((file) => file.targetPath)).toEqual([
311
+ (await fs.realpath(firstUploadedPath)).replace(/\\/g, '/'),
312
+ (await fs.realpath(secondUploadedPath)).replace(/\\/g, '/')
313
+ ]);
314
+ await expect(fs.readFile(firstUploadedPath, 'utf-8')).resolves.toBe('first nested file');
315
+ await expect(fs.readFile(secondUploadedPath, 'utf-8')).resolves.toBe('second nested file');
316
+ });
317
+ it('rejects unsafe relative upload paths', async () => {
318
+ const uploadSource = path.join(tempRoot, 'unsafe-folder-upload.txt');
319
+ const uploadTargetDir = path.join(workspacePath, 'unsafe-folder-uploads');
320
+ await fs.mkdir(uploadTargetDir, { recursive: true });
321
+ await fs.writeFile(uploadSource, 'unsafe content', 'utf-8');
322
+ await expect(uploadWorkspaceFiles({
323
+ workspacePath,
324
+ targetPath: uploadTargetDir,
325
+ files: [{ originalname: 'safe.txt', relativePath: 'project/../escape.txt', path: uploadSource, size: 14 }]
326
+ })).rejects.toThrow(/entryName is invalid/);
327
+ });
328
+ it('uploads binary exe files and lists them after upload', async () => {
329
+ const uploadSource = path.join(tempRoot, 'demo-app-source.exe');
330
+ const uploadTargetDir = path.join(workspacePath, 'bin');
331
+ const exeBytes = Buffer.from([0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00, 0xff, 0x00]);
332
+ await fs.mkdir(uploadTargetDir, { recursive: true });
333
+ await fs.writeFile(uploadSource, exeBytes);
334
+ const uploadResult = await uploadWorkspaceFiles({
335
+ workspacePath,
336
+ targetPath: uploadTargetDir,
337
+ files: [{ originalname: 'demo-app.exe', path: uploadSource, size: exeBytes.length }]
338
+ });
339
+ const uploadedPath = path.join(uploadTargetDir, 'demo-app.exe');
340
+ expect(uploadResult.files[0]).toMatchObject({
341
+ fileName: 'demo-app.exe',
342
+ size: exeBytes.length
343
+ });
344
+ await expect(fs.readFile(uploadedPath)).resolves.toEqual(exeBytes);
345
+ const listedDirectory = await listWorkspaceDirectory({
346
+ workspacePath,
347
+ targetPath: uploadTargetDir
348
+ });
349
+ expect(listedDirectory.items.some((item) => item.name === 'demo-app.exe' && !item.isDirectory)).toBe(true);
350
+ });
351
+ it('decodes latin1-mojibake UTF-8 upload file names before saving', async () => {
352
+ const uploadSource = path.join(tempRoot, 'springboot-source.zip');
353
+ const uploadTargetDir = path.join(workspacePath, 'encoded-uploads');
354
+ const originalName = 'springboot测试压缩包2.zip';
355
+ const mojibakeName = Buffer.from(originalName, 'utf8').toString('latin1');
356
+ await fs.mkdir(uploadTargetDir, { recursive: true });
357
+ await fs.writeFile(uploadSource, 'zip bytes', 'utf-8');
358
+ const uploadResult = await uploadWorkspaceFiles({
359
+ workspacePath,
360
+ targetPath: uploadTargetDir,
361
+ files: [{ originalname: mojibakeName, path: uploadSource, size: 9 }]
362
+ });
363
+ const uploadedPath = path.join(uploadTargetDir, originalName);
364
+ expect(uploadResult.files[0].fileName).toBe(originalName);
365
+ await expect(fs.readFile(uploadedPath, 'utf-8')).resolves.toBe('zip bytes');
366
+ const listedDirectory = await listWorkspaceDirectory({
367
+ workspacePath,
368
+ targetPath: uploadTargetDir
369
+ });
370
+ expect(listedDirectory.items.some((item) => item.name === originalName)).toBe(true);
371
+ });
372
+ it('extracts uploaded zip archives into a directory named after the archive', async () => {
373
+ const uploadTargetDir = path.join(workspacePath, 'zip-auto-extract');
374
+ const uploadSource = path.join(tempRoot, 'project-package-source.zip');
375
+ await fs.mkdir(uploadTargetDir, { recursive: true });
376
+ await new Promise((resolve, reject) => {
377
+ const archive = new ZipArchive();
378
+ const output = createWriteStream(uploadSource);
379
+ output.on('close', resolve);
380
+ archive.on('error', reject);
381
+ archive.pipe(output);
382
+ archive.append('first file', { name: 'a.txt' });
383
+ archive.append('second file', { name: 'nested/b.txt' });
384
+ archive.finalize().catch(reject);
385
+ });
386
+ const uploadResult = await uploadWorkspaceFiles({
387
+ workspacePath,
388
+ targetPath: uploadTargetDir,
389
+ extractArchives: true,
390
+ files: [{ originalname: 'project-package.zip', path: uploadSource, size: (await fs.stat(uploadSource)).size }]
391
+ });
392
+ const extractedRoot = path.join(uploadTargetDir, 'project-package');
393
+ expect(uploadResult.files[0].extracted?.targetPath).toBe((await fs.realpath(extractedRoot)).replace(/\\/g, '/'));
394
+ expect(uploadResult.files[0].extracted?.extractedEntries).toBe(2);
395
+ await expect(fs.readFile(path.join(extractedRoot, 'a.txt'), 'utf-8')).resolves.toBe('first file');
396
+ await expect(fs.readFile(path.join(extractedRoot, 'nested', 'b.txt'), 'utf-8')).resolves.toBe('second file');
397
+ await expect(fs.access(path.join(uploadTargetDir, 'a.txt'))).rejects.toThrow();
398
+ });
399
+ it('extracts uploaded tar archives into a directory named after the archive', async () => {
400
+ const uploadTargetDir = path.join(workspacePath, 'tar-auto-extract');
401
+ const tarSourceDir = path.join(tempRoot, 'tar-source');
402
+ const uploadSource = path.join(tempRoot, 'bundle-source.tar');
403
+ await fs.mkdir(path.join(tarSourceDir, 'nested'), { recursive: true });
404
+ await fs.mkdir(uploadTargetDir, { recursive: true });
405
+ await fs.writeFile(path.join(tarSourceDir, 'root.txt'), 'root tar file', 'utf-8');
406
+ await fs.writeFile(path.join(tarSourceDir, 'nested', 'child.txt'), 'child tar file', 'utf-8');
407
+ await tar.c({ file: uploadSource, cwd: tarSourceDir }, ['root.txt', 'nested/child.txt']);
408
+ const uploadResult = await uploadWorkspaceFiles({
409
+ workspacePath,
410
+ targetPath: uploadTargetDir,
411
+ extractArchives: true,
412
+ files: [{ originalname: 'bundle.tar', path: uploadSource, size: (await fs.stat(uploadSource)).size }]
413
+ });
414
+ const extractedRoot = path.join(uploadTargetDir, 'bundle');
415
+ expect(uploadResult.files[0].extracted?.targetPath).toBe((await fs.realpath(extractedRoot)).replace(/\\/g, '/'));
416
+ expect(uploadResult.files[0].extracted?.extractedEntries).toBe(2);
417
+ await expect(fs.readFile(path.join(extractedRoot, 'root.txt'), 'utf-8')).resolves.toBe('root tar file');
418
+ await expect(fs.readFile(path.join(extractedRoot, 'nested', 'child.txt'), 'utf-8')).resolves.toBe('child tar file');
419
+ await expect(fs.access(path.join(uploadTargetDir, 'root.txt'))).rejects.toThrow();
420
+ });
421
+ it('does not expose outside target size for file symlinks in directory listings', async () => {
422
+ const outsideFile = path.join(tempRoot, 'outside-secret.txt');
423
+ const symlinkPath = path.join(workspacePath, 'outside-secret-link.txt');
424
+ await fs.writeFile(outsideFile, 'outside-secret-content', 'utf-8');
425
+ try {
426
+ await fs.symlink(outsideFile, symlinkPath, 'file');
427
+ }
428
+ catch (error) {
429
+ const errorCode = typeof error === 'object' && error !== null ? Reflect.get(error, 'code') : undefined;
430
+ if (errorCode === 'EPERM' || errorCode === 'EACCES') {
431
+ return;
432
+ }
433
+ throw error;
434
+ }
435
+ const result = await listWorkspaceDirectory({
436
+ workspacePath,
437
+ targetPath: workspacePath
438
+ });
439
+ const symlinkItem = result.items.find((item) => item.name === 'outside-secret-link.txt');
440
+ expect(symlinkItem).toBeDefined();
441
+ expect(symlinkItem?.size).toBeUndefined();
442
+ });
443
+ it('resolves file and directory download targets safely', async () => {
444
+ const fileTarget = await resolveWorkspaceDownloadTarget({
445
+ workspacePath,
446
+ targetPath: nestedFile
447
+ });
448
+ expect(fileTarget.isDirectory).toBe(false);
449
+ expect(fileTarget.fileName).toBe('index.ts');
450
+ const directoryTarget = await resolveWorkspaceDownloadTarget({
451
+ workspacePath,
452
+ targetPath: nestedDir
453
+ });
454
+ expect(directoryTarget.isDirectory).toBe(true);
455
+ expect(directoryTarget.fileName).toBe('src.zip');
456
+ });
457
+ it('streams a workspace directory as a zip archive', async () => {
458
+ const zipPath = path.join(tempRoot, 'src.zip');
459
+ await streamWorkspaceDirectoryZip(nestedDir, createWriteStream(zipPath));
460
+ const stat = await fs.stat(zipPath);
461
+ expect(stat.size).toBeGreaterThan(0);
462
+ });
463
+ it('extracts zip archives and rejects path traversal entries', async () => {
464
+ const goodZip = path.join(workspacePath, 'archive.zip');
465
+ const badZip = path.join(workspacePath, 'bad.zip');
466
+ const extractDir = path.join(workspacePath, 'extracted');
467
+ await new Promise((resolve, reject) => {
468
+ const archive = new ZipArchive();
469
+ const output = createWriteStream(goodZip);
470
+ output.on('close', resolve);
471
+ archive.on('error', reject);
472
+ archive.pipe(output);
473
+ archive.append('zip content', { name: 'nested/file.txt' });
474
+ archive.finalize().catch(reject);
475
+ });
476
+ const extracted = await extractWorkspaceArchive({
477
+ workspacePath,
478
+ archivePath: goodZip,
479
+ outputPath: extractDir
480
+ });
481
+ expect(extracted.extractedEntries).toBe(1);
482
+ await expect(fs.readFile(path.join(extractDir, 'nested', 'file.txt'), 'utf-8')).resolves.toBe('zip content');
483
+ await writeStoredZip(badZip, '../escape.txt', 'escape');
484
+ await expect(extractWorkspaceArchive({
485
+ workspacePath,
486
+ archivePath: badZip,
487
+ outputPath: extractDir
488
+ })).rejects.toThrow(/Archive entry path is invalid|outside workspace/);
489
+ });
490
+ it('extracts GBK encoded zip entry names as Chinese paths', async () => {
491
+ const gbkZip = path.join(workspacePath, 'gbk-archive.zip');
492
+ const extractDir = path.join(workspacePath, 'gbk-extracted');
493
+ const chineseDirectoryName = 'springboot067摄影师社区2';
494
+ const gbkEntryNameBytes = Buffer.from([
495
+ 115, 112, 114, 105, 110, 103, 98, 111, 111, 116, 48, 54, 55,
496
+ 201, 227, 211, 176, 202, 166, 201, 231, 199, 248, 50,
497
+ 47, 114, 101, 97, 100, 109, 101, 46, 116, 120, 116
498
+ ]);
499
+ await writeStoredZipWithNameBytes(gbkZip, gbkEntryNameBytes, '中文目录内容');
500
+ const extracted = await extractWorkspaceArchive({
501
+ workspacePath,
502
+ archivePath: gbkZip,
503
+ outputPath: extractDir
504
+ });
505
+ expect(extracted.extractedEntries).toBe(1);
506
+ await expect(fs.readFile(path.join(extractDir, chineseDirectoryName, 'readme.txt'), 'utf-8'))
507
+ .resolves.toBe('中文目录内容');
508
+ });
509
+ it('returns docx preview metadata without parsing document contents', async () => {
510
+ const docxPath = path.join(workspacePath, 'large-preview.docx');
511
+ const largeImageLikePayload = Buffer.alloc(21 * 1024 * 1024, 1);
512
+ await fs.writeFile(docxPath, largeImageLikePayload);
513
+ const preview = await previewWorkspaceDocument({ workspacePath, filePath: docxPath });
514
+ expect(preview.kind).toBe('html');
515
+ expect(preview.content).toBe('');
516
+ expect(preview.size).toBe(largeImageLikePayload.length);
517
+ expect(preview.mtimeMs).toBeGreaterThan(0);
518
+ });
519
+ it('returns unsupported preview metadata for legacy doc files', async () => {
520
+ const docPath = path.join(workspacePath, 'legacy.doc');
521
+ await fs.writeFile(docPath, 'legacy word bytes', 'utf-8');
522
+ const preview = await previewWorkspaceDocument({ workspacePath, filePath: docPath });
523
+ expect(preview.kind).toBe('unsupported');
524
+ expect(preview.message).toMatch(/暂不支持旧版 \.doc/);
525
+ });
526
+ it('rejects workspace access through symlink escapes', async () => {
527
+ const outsideDir = path.join(tempRoot, 'outside');
528
+ const symlinkPath = path.join(workspacePath, 'outside-link');
529
+ await fs.mkdir(outsideDir, { recursive: true });
530
+ await fs.writeFile(path.join(outsideDir, 'secret.txt'), 'secret', 'utf-8');
531
+ try {
532
+ await fs.symlink(outsideDir, symlinkPath, 'dir');
533
+ }
534
+ catch (error) {
535
+ const errorCode = typeof error === 'object' && error !== null ? Reflect.get(error, 'code') : undefined;
536
+ if (errorCode === 'EPERM') {
537
+ return;
538
+ }
539
+ throw error;
540
+ }
541
+ await expect(readWorkspaceFile({
542
+ workspacePath,
543
+ filePath: path.join(symlinkPath, 'secret.txt')
544
+ })).rejects.toThrow(/outside workspace/);
545
+ });
546
+ it('rejects tar archives with symbolic links', async () => {
547
+ const archivePath = path.join(workspacePath, 'link.tar');
548
+ const linkTarget = path.join(tempRoot, 'linked-target');
549
+ const sourceLink = path.join(tempRoot, 'archive-link');
550
+ await fs.mkdir(linkTarget, { recursive: true });
551
+ try {
552
+ await fs.symlink(linkTarget, sourceLink, 'dir');
553
+ }
554
+ catch (error) {
555
+ const errorCode = typeof error === 'object' && error !== null ? Reflect.get(error, 'code') : undefined;
556
+ if (errorCode === 'EPERM') {
557
+ return;
558
+ }
559
+ throw error;
560
+ }
561
+ await tar.c({ file: archivePath, cwd: tempRoot }, ['archive-link']);
562
+ await expect(extractWorkspaceArchive({
563
+ workspacePath,
564
+ archivePath,
565
+ outputPath: workspacePath
566
+ })).rejects.toThrow(/links are not supported/);
567
+ });
568
+ it('rejects tar archives with unsupported special entries', async () => {
569
+ const archivePath = path.join(workspacePath, 'special-entry.tar');
570
+ await writeTarHeaderOnlyEntry(archivePath, 'special-device', '3');
571
+ await expect(extractWorkspaceArchive({
572
+ workspacePath,
573
+ archivePath,
574
+ outputPath: workspacePath
575
+ })).rejects.toThrow(/Unsupported archive entry type/);
576
+ });
117
577
  });
@@ -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"}