staff-mcp 1.2.0 → 2.0.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.
Files changed (133) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +178 -49
  3. package/README_zh.md +176 -47
  4. package/dist/src/data/HttpDataSession.d.ts +22 -0
  5. package/dist/src/data/HttpDataSession.js +925 -0
  6. package/dist/src/data/HttpDataSession.js.map +1 -0
  7. package/dist/src/data/node.d.ts +4 -0
  8. package/dist/src/data/node.js +93 -0
  9. package/dist/src/data/node.js.map +1 -0
  10. package/dist/src/data/types.d.ts +54 -0
  11. package/dist/src/data/types.js +2 -0
  12. package/dist/src/data/types.js.map +1 -0
  13. package/dist/src/data-stdio-docker.d.ts +10 -0
  14. package/dist/src/data-stdio-docker.js +140 -0
  15. package/dist/src/data-stdio-docker.js.map +1 -0
  16. package/dist/src/docker-proxy.d.ts +6 -1
  17. package/dist/src/docker-proxy.js +324 -147
  18. package/dist/src/docker-proxy.js.map +1 -1
  19. package/dist/src/extensions/apps.d.ts +12 -0
  20. package/dist/src/extensions/apps.js +85 -0
  21. package/dist/src/extensions/apps.js.map +1 -0
  22. package/dist/src/extensions/config.d.ts +27 -0
  23. package/dist/src/extensions/config.js +91 -0
  24. package/dist/src/extensions/config.js.map +1 -0
  25. package/dist/src/extensions/protocol.d.ts +35 -0
  26. package/dist/src/extensions/protocol.js +47 -0
  27. package/dist/src/extensions/protocol.js.map +1 -0
  28. package/dist/src/extensions/runtime.d.ts +16 -0
  29. package/dist/src/extensions/runtime.js +266 -0
  30. package/dist/src/extensions/runtime.js.map +1 -0
  31. package/dist/src/file-manager/extension.d.ts +21 -0
  32. package/dist/src/file-manager/extension.js +514 -0
  33. package/dist/src/file-manager/extension.js.map +1 -0
  34. package/dist/src/file-manager/file-core.d.ts +128 -0
  35. package/dist/src/file-manager/file-core.js +750 -0
  36. package/dist/src/file-manager/file-core.js.map +1 -0
  37. package/dist/src/file-manager/transfers.d.ts +423 -0
  38. package/dist/src/file-manager/transfers.js +540 -0
  39. package/dist/src/file-manager/transfers.js.map +1 -0
  40. package/dist/src/file-manager/ui/index.html +81 -0
  41. package/dist/src/index.js +289 -128
  42. package/dist/src/index.js.map +1 -1
  43. package/dist/src/lsp/manager.d.ts +2 -0
  44. package/dist/src/lsp/manager.js +22 -10
  45. package/dist/src/lsp/manager.js.map +1 -1
  46. package/dist/src/reverse/SseTransport.d.ts +34 -0
  47. package/dist/src/reverse/SseTransport.js +215 -0
  48. package/dist/src/reverse/SseTransport.js.map +1 -0
  49. package/dist/src/reverse/http.d.ts +7 -0
  50. package/dist/src/reverse/http.js +85 -0
  51. package/dist/src/reverse/http.js.map +1 -0
  52. package/dist/src/server.d.ts +4 -2
  53. package/dist/src/server.js +80 -18
  54. package/dist/src/server.js.map +1 -1
  55. package/dist/src/terminal/extension.d.ts +4 -0
  56. package/dist/src/terminal/extension.js +108 -0
  57. package/dist/src/terminal/extension.js.map +1 -0
  58. package/dist/src/terminal/pty.d.ts +14 -0
  59. package/dist/src/terminal/pty.js +121 -0
  60. package/dist/src/terminal/pty.js.map +1 -0
  61. package/dist/src/terminal/session.d.ts +136 -0
  62. package/dist/src/terminal/session.js +422 -0
  63. package/dist/src/terminal/session.js.map +1 -0
  64. package/dist/src/terminal/ui/index.html +183 -0
  65. package/dist/src/tools/file-tools.d.ts +2 -1
  66. package/dist/src/tools/file-tools.js +19 -7
  67. package/dist/src/tools/file-tools.js.map +1 -1
  68. package/dist/src/tools/mcp-client-tools.js +244 -104
  69. package/dist/src/tools/mcp-client-tools.js.map +1 -1
  70. package/dist/src/tools/shell-tools.js +1 -1
  71. package/dist/src/tools/shell-tools.js.map +1 -1
  72. package/dist/src/tools/skills.js +26 -1
  73. package/dist/src/tools/skills.js.map +1 -1
  74. package/dist/src/tools/system-tools.d.ts +9 -3
  75. package/dist/src/tools/system-tools.js +31 -48
  76. package/dist/src/tools/system-tools.js.map +1 -1
  77. package/dist/src/transports/http.d.ts +16 -2
  78. package/dist/src/transports/http.js +261 -59
  79. package/dist/src/transports/http.js.map +1 -1
  80. package/dist/src/transports/reverse.d.ts +23 -19
  81. package/dist/src/transports/reverse.js +130 -74
  82. package/dist/src/transports/reverse.js.map +1 -1
  83. package/dist/src/transports/stdio.d.ts +6 -2
  84. package/dist/src/transports/stdio.js +131 -19
  85. package/dist/src/transports/stdio.js.map +1 -1
  86. package/dist/src/utils/proxy.d.ts +3 -3
  87. package/dist/src/utils/proxy.js +84 -62
  88. package/dist/src/utils/proxy.js.map +1 -1
  89. package/dist/src/utils/server-activity.d.ts +6 -0
  90. package/dist/src/utils/server-activity.js +46 -0
  91. package/dist/src/utils/server-activity.js.map +1 -0
  92. package/dist/src/utils/server-lifecycle.d.ts +5 -0
  93. package/dist/src/utils/server-lifecycle.js +51 -0
  94. package/dist/src/utils/server-lifecycle.js.map +1 -0
  95. package/docs/file-manager.md +179 -0
  96. package/docs/terminal.md +66 -0
  97. package/package.json +40 -19
  98. package/dist/src/transports/fte-lifecycle.d.ts +0 -13
  99. package/dist/src/transports/fte-lifecycle.js +0 -76
  100. package/dist/src/transports/fte-lifecycle.js.map +0 -1
  101. package/dist/tests/docker-proxy.test.d.ts +0 -1
  102. package/dist/tests/docker-proxy.test.js +0 -395
  103. package/dist/tests/docker-proxy.test.js.map +0 -1
  104. package/dist/tests/file-write-queue.test.d.ts +0 -1
  105. package/dist/tests/file-write-queue.test.js +0 -105
  106. package/dist/tests/file-write-queue.test.js.map +0 -1
  107. package/dist/tests/fte-lifecycle.test.d.ts +0 -1
  108. package/dist/tests/fte-lifecycle.test.js +0 -118
  109. package/dist/tests/fte-lifecycle.test.js.map +0 -1
  110. package/dist/tests/lsp-client.test.d.ts +0 -1
  111. package/dist/tests/lsp-client.test.js +0 -52
  112. package/dist/tests/lsp-client.test.js.map +0 -1
  113. package/dist/tests/package-info.test.d.ts +0 -1
  114. package/dist/tests/package-info.test.js +0 -54
  115. package/dist/tests/package-info.test.js.map +0 -1
  116. package/dist/tests/proxy-no-proxy.test.d.ts +0 -1
  117. package/dist/tests/proxy-no-proxy.test.js +0 -156
  118. package/dist/tests/proxy-no-proxy.test.js.map +0 -1
  119. package/dist/tests/proxy.test.d.ts +0 -1
  120. package/dist/tests/proxy.test.js +0 -89
  121. package/dist/tests/proxy.test.js.map +0 -1
  122. package/dist/tests/reverse-reconnect.test.d.ts +0 -1
  123. package/dist/tests/reverse-reconnect.test.js +0 -89
  124. package/dist/tests/reverse-reconnect.test.js.map +0 -1
  125. package/dist/tests/security.test.d.ts +0 -1
  126. package/dist/tests/security.test.js +0 -21
  127. package/dist/tests/security.test.js.map +0 -1
  128. package/dist/tests/shell-tools.test.d.ts +0 -1
  129. package/dist/tests/shell-tools.test.js +0 -113
  130. package/dist/tests/shell-tools.test.js.map +0 -1
  131. package/dist/tests/test_files/test.d.ts +0 -1
  132. package/dist/tests/test_files/test.js +0 -4
  133. package/dist/tests/test_files/test.js.map +0 -1
@@ -0,0 +1,750 @@
1
+ import { constants } from 'node:fs';
2
+ import * as fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ export const TEXT_LIMIT = 2 * 1024 * 1024;
6
+ export const FILE_LIMIT = 100 * 1024 * 1024;
7
+ export const CHUNK_LIMIT = 512 * 1024;
8
+ export class FileError extends Error {
9
+ code;
10
+ rpcCode;
11
+ constructor(code, message, rpcCode = -32000) {
12
+ super(message);
13
+ this.code = code;
14
+ this.rpcCode = rpcCode;
15
+ }
16
+ }
17
+ export function fail(code, message) {
18
+ throw new FileError(code, message);
19
+ }
20
+ export function fileError(error) {
21
+ if (error instanceof FileError)
22
+ return error;
23
+ const code = error?.code;
24
+ const mapped = {
25
+ ENOENT: 'NOT_FOUND',
26
+ ENOTDIR: 'NOT_DIRECTORY',
27
+ EISDIR: 'IS_DIRECTORY',
28
+ EEXIST: 'ALREADY_EXISTS',
29
+ ENOTEMPTY: 'NOT_EMPTY',
30
+ EACCES: 'PERMISSION_DENIED',
31
+ EPERM: 'PERMISSION_DENIED',
32
+ ELOOP: 'SYMLINK_NOT_ALLOWED',
33
+ ENOSPC: 'NO_SPACE',
34
+ EROFS: 'READ_ONLY',
35
+ };
36
+ return new FileError(mapped[code ?? ''] ?? 'IO_ERROR', mapped[code ?? '']
37
+ ? `File operation failed: ${mapped[code ?? '']}`
38
+ : 'File operation failed');
39
+ }
40
+ export class Mutex {
41
+ tail = Promise.resolve();
42
+ async run(fn) {
43
+ const previous = this.tail;
44
+ let release;
45
+ this.tail = new Promise((resolve) => {
46
+ release = resolve;
47
+ });
48
+ await previous;
49
+ try {
50
+ return await fn();
51
+ }
52
+ finally {
53
+ release();
54
+ }
55
+ }
56
+ }
57
+ export function relativePath(value) {
58
+ if (typeof value !== 'string' ||
59
+ value.includes('\0') ||
60
+ value.includes('\\') ||
61
+ path.isAbsolute(value) ||
62
+ /^[a-zA-Z]:/.test(value))
63
+ fail('OUTSIDE_ROOT', 'Expected a root-relative path');
64
+ // RPC paths and filesystem names are literal, not URI-encoded. A '%' (even
65
+ // '%2e%2e') is part of the name; only the resource URI boundary decodes it.
66
+ if (value === '')
67
+ return '';
68
+ const parts = value.split('/');
69
+ if (parts.some((part) => !part || part === '.' || part === '..'))
70
+ fail('OUTSIDE_ROOT', 'Invalid path component');
71
+ if (parts.some((part) => part.startsWith('.staff-write-')))
72
+ fail('OUTSIDE_ROOT', 'Reserved temporary path');
73
+ return value;
74
+ }
75
+ export function mimeType(file) {
76
+ return ({
77
+ '.md': 'text/markdown',
78
+ '.txt': 'text/plain',
79
+ '.json': 'application/json',
80
+ '.html': 'text/html',
81
+ '.svg': 'image/svg+xml',
82
+ '.png': 'image/png',
83
+ '.jpg': 'image/jpeg',
84
+ '.jpeg': 'image/jpeg',
85
+ '.gif': 'image/gif',
86
+ '.webp': 'image/webp',
87
+ }[path.extname(file).toLowerCase()] ?? 'application/octet-stream');
88
+ }
89
+ export function utf8(bytes) {
90
+ let text;
91
+ try {
92
+ text = new TextDecoder('utf-8', {
93
+ fatal: true,
94
+ ignoreBOM: true,
95
+ }).decode(bytes);
96
+ }
97
+ catch {
98
+ return fail('BINARY_FILE', 'Not valid UTF-8; download this file instead');
99
+ }
100
+ if (/[\u0000-\u0008\u000b\u000e-\u001f]/.test(text))
101
+ fail('BINARY_FILE', 'Binary content; download this file instead');
102
+ return text;
103
+ }
104
+ function inside(root, candidate) {
105
+ const rel = path.relative(root, candidate);
106
+ return (rel === '' ||
107
+ (!rel.startsWith(`..${path.sep}`) &&
108
+ rel !== '..' &&
109
+ !path.isAbsolute(rel)));
110
+ }
111
+ export async function secureDirectory(directory) {
112
+ const full = path.resolve(directory);
113
+ let current = path.parse(full).root;
114
+ const parts = full.slice(current.length).split(path.sep).filter(Boolean);
115
+ for (const part of parts) {
116
+ current = path.join(current, part);
117
+ try {
118
+ await fs.mkdir(current, { mode: 0o700 });
119
+ }
120
+ catch (e) {
121
+ if (e.code !== 'EEXIST')
122
+ throw e;
123
+ }
124
+ const st = await fs.lstat(current);
125
+ if (st.isSymbolicLink() || !st.isDirectory())
126
+ fail('SYMLINK_NOT_ALLOWED', 'Directory ancestry must not contain links');
127
+ }
128
+ }
129
+ export class FileCore {
130
+ mutex;
131
+ changes;
132
+ publications;
133
+ privateDirectories;
134
+ listeners = new Set();
135
+ constructor(roots, privateDirectory, shared, privateDirectoryAliases = []) {
136
+ // Standard resources/tools and files v1 have independent roots, but serialize
137
+ // writes and observe the same filesystem publications.
138
+ // Aliases only add restrictions. Never replace canonical staging, even
139
+ // when callers supply the internal Docker paths explicitly.
140
+ this.privateDirectories = [
141
+ ...new Set([
142
+ ...(shared?.privateDirectories ?? []),
143
+ ...(privateDirectory ? [path.resolve(privateDirectory)] : []),
144
+ ...privateDirectoryAliases.map((directory) => {
145
+ if (!path.isAbsolute(directory) || directory.includes('\0'))
146
+ throw new Error('Private directory alias must be absolute');
147
+ return path.resolve(directory);
148
+ }),
149
+ ]),
150
+ ];
151
+ this.mutex = shared?.mutex ?? new Mutex();
152
+ this.publications = shared?.publications ?? new Set();
153
+ this.changes = shared?.changes ?? new Set();
154
+ this.changes.add((full) => this.notifyChanged(full));
155
+ this.rootMap = new Map(roots.map((root) => [
156
+ root.rootId,
157
+ {
158
+ ...root,
159
+ directory: path.resolve(root.directory),
160
+ },
161
+ ]));
162
+ }
163
+ rootMap;
164
+ roots() {
165
+ return {
166
+ roots: [...this.rootMap.values()].map(({ rootId, name, readOnly }) => ({ rootId, name, readOnly })),
167
+ };
168
+ }
169
+ root(id, write = false) {
170
+ const root = this.rootMap.get(id);
171
+ if (!root)
172
+ fail('UNKNOWN_ROOT', 'Unknown rootId');
173
+ if (write && root.readOnly)
174
+ fail('READ_ONLY', 'This root is read-only');
175
+ return root;
176
+ }
177
+ async resolve(rootId, input, options = {}) {
178
+ const root = this.root(rootId, options.write);
179
+ const rel = relativePath(input);
180
+ const full = path.resolve(root.directory, rel);
181
+ if (!inside(root.directory, full))
182
+ fail('OUTSIDE_ROOT', 'Path leaves allowed root');
183
+ this.checkPrivate(full);
184
+ // Recheck all ancestry on every operation, including the configured root itself.
185
+ let current = path.parse(root.directory).root;
186
+ const rootParts = root.directory
187
+ .slice(current.length)
188
+ .split(path.sep)
189
+ .filter(Boolean);
190
+ const parts = [...rootParts, ...(rel ? rel.split('/') : [])];
191
+ for (let i = 0; i < parts.length; i++) {
192
+ current = path.join(current, parts[i]);
193
+ let st;
194
+ try {
195
+ st = await fs.lstat(current);
196
+ }
197
+ catch (e) {
198
+ if (e.code === 'ENOENT' &&
199
+ options.missing &&
200
+ i === parts.length - 1 &&
201
+ rel)
202
+ return full;
203
+ throw e;
204
+ }
205
+ if (st.isSymbolicLink()) {
206
+ if (options.allowLeafLink && i === parts.length - 1 && rel)
207
+ return full;
208
+ fail('SYMLINK_NOT_ALLOWED', 'Symbolic links are not followed');
209
+ }
210
+ if (!st.isDirectory() && !st.isFile())
211
+ fail('NOT_FILE', 'Special filesystem objects are not supported');
212
+ if (i < parts.length - 1 && !st.isDirectory())
213
+ fail('NOT_DIRECTORY', 'A parent is not a directory');
214
+ const real = await fs.realpath(current);
215
+ if (i >= rootParts.length - 1 && !inside(root.directory, real))
216
+ fail('OUTSIDE_ROOT', 'Real path leaves allowed root');
217
+ }
218
+ return full;
219
+ }
220
+ changed(rootId, file) {
221
+ const full = path.resolve(this.root(rootId).directory, file);
222
+ for (const notify of this.changes)
223
+ notify(full);
224
+ }
225
+ notifyChanged(full) {
226
+ for (const root of this.rootMap.values()) {
227
+ if (!inside(root.directory, full))
228
+ continue;
229
+ const relative = path
230
+ .relative(root.directory, full)
231
+ .split(path.sep)
232
+ .join('/');
233
+ for (const listener of this.listeners) {
234
+ try {
235
+ listener(root.rootId, relative);
236
+ }
237
+ catch {
238
+ /* observers cannot undo a successful write */
239
+ }
240
+ }
241
+ }
242
+ }
243
+ checkPrivate(full) {
244
+ if (this.privateDirectories.some((directory) => inside(directory, full)))
245
+ fail('OUTSIDE_ROOT', 'Private transfer staging is not a workspace file');
246
+ }
247
+ protectRoots(full) {
248
+ this.checkPrivate(full);
249
+ if (this.privateDirectories.some((directory) => inside(full, directory)))
250
+ fail('ROOT_PROTECTED', 'Cannot remove private staging ancestry');
251
+ for (const root of this.rootMap.values()) {
252
+ if (inside(full, root.directory))
253
+ fail('ROOT_PROTECTED', 'Cannot remove or move a configured root or its ancestor');
254
+ }
255
+ for (const temporary of this.publications) {
256
+ if (inside(full, temporary))
257
+ fail('BUSY', 'An in-flight file publication uses this path');
258
+ }
259
+ }
260
+ /** Standard tools retain their path and result contracts, but share the extension's lock. */
261
+ async validateToolPath(full, missingParents = false) {
262
+ this.checkPrivate(full);
263
+ const roots = [...this.rootMap.values()]
264
+ .filter((root) => inside(root.directory, full))
265
+ .sort((a, b) => b.directory.length - a.directory.length);
266
+ const root = roots[0];
267
+ if (!root)
268
+ fail('OUTSIDE_ROOT', 'Path leaves allowed roots');
269
+ const relative = path
270
+ .relative(root.directory, full)
271
+ .split(path.sep)
272
+ .join('/');
273
+ if (relative.split('/').some((part) => part.startsWith('.staff-write-')))
274
+ fail('OUTSIDE_ROOT', 'Reserved temporary path');
275
+ // Preserve ordinary tools' literal filenames and recursive parent creation.
276
+ // Unlike extension RPC paths, these have already been resolved by SecurityManager.
277
+ let current = path.parse(full).root;
278
+ // The filesystem root already includes the Windows drive or UNC share.
279
+ for (const part of full
280
+ .slice(current.length)
281
+ .split(path.sep)
282
+ .filter(Boolean)) {
283
+ current = path.join(current, part);
284
+ try {
285
+ const st = await fs.lstat(current);
286
+ if (st.isSymbolicLink())
287
+ fail('SYMLINK_NOT_ALLOWED', 'Symbolic links are not followed');
288
+ if (!st.isDirectory() && !st.isFile())
289
+ fail('NOT_FILE', 'Unsupported filesystem object');
290
+ if (current !== full && !st.isDirectory())
291
+ fail('NOT_DIRECTORY', 'Parent is not a directory');
292
+ }
293
+ catch (e) {
294
+ if (!missingParents ||
295
+ e.code !== 'ENOENT')
296
+ throw e;
297
+ }
298
+ }
299
+ return full;
300
+ }
301
+ withWriteLock(task) {
302
+ return this.mutex.run(task);
303
+ }
304
+ async toolWrite(full, task, missingParents = false) {
305
+ return this.mutex.run(async () => {
306
+ this.protectRoots(full);
307
+ await this.validateToolPath(full, missingParents);
308
+ const result = await task();
309
+ if (!(result &&
310
+ typeof result === 'object' &&
311
+ 'isError' in result &&
312
+ result.isError)) {
313
+ const root = [...this.rootMap.values()].find((root) => inside(root.directory, full));
314
+ this.changed(root.rootId, path
315
+ .relative(root.directory, full)
316
+ .split(path.sep)
317
+ .join('/'));
318
+ }
319
+ return result;
320
+ });
321
+ }
322
+ async hashFile(full) {
323
+ const handle = await fs.open(full, constants.O_RDONLY | constants.O_NOFOLLOW);
324
+ try {
325
+ const before = await handle.stat();
326
+ if (!before.isFile())
327
+ fail('NOT_FILE', 'Expected a regular file');
328
+ const hash = createHash('sha256');
329
+ const buffer = Buffer.alloc(CHUNK_LIMIT);
330
+ let offset = 0;
331
+ while (true) {
332
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
333
+ if (!bytesRead)
334
+ break;
335
+ hash.update(buffer.subarray(0, bytesRead));
336
+ offset += bytesRead;
337
+ }
338
+ const after = await handle.stat();
339
+ if (before.size !== after.size ||
340
+ before.mtimeMs !== after.mtimeMs ||
341
+ before.ctimeMs !== after.ctimeMs)
342
+ fail('VERSION_CONFLICT', 'File changed while reading');
343
+ return hash.digest('hex');
344
+ }
345
+ finally {
346
+ await handle.close();
347
+ }
348
+ }
349
+ async version(full) {
350
+ const st = await fs.lstat(full);
351
+ if (st.isSymbolicLink())
352
+ fail('SYMLINK_NOT_ALLOWED', 'Symbolic links are not followed');
353
+ if (st.isFile())
354
+ return this.hashFile(full);
355
+ if (!st.isDirectory())
356
+ fail('NOT_FILE', 'Unsupported file type');
357
+ const names = (await fs.readdir(full)).sort();
358
+ return createHash('sha256')
359
+ .update(`${st.ino}:${st.mtimeMs}:${st.ctimeMs}:${JSON.stringify(names)}`)
360
+ .digest('hex');
361
+ }
362
+ async stat(rootId, file, includeVersion = false) {
363
+ const full = await this.resolve(rootId, file);
364
+ const st = await fs.lstat(full);
365
+ const readOnly = this.root(rootId).readOnly;
366
+ return {
367
+ path: file,
368
+ type: st.isDirectory() ? 'directory' : 'file',
369
+ size: st.size,
370
+ mtime: st.mtime.toISOString(),
371
+ ...(includeVersion ? { version: await this.version(full) } : {}),
372
+ readOnly,
373
+ mimeType: mimeType(file),
374
+ operations: {
375
+ read: st.isFile(),
376
+ write: !readOnly && st.isFile(),
377
+ delete: !readOnly && file !== '',
378
+ move: !readOnly && file !== '',
379
+ },
380
+ };
381
+ }
382
+ async list(rootId, file, cursor, limit = 100) {
383
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500)
384
+ fail('INVALID_PARAMS', 'limit must be 1..500');
385
+ const full = await this.resolve(rootId, file);
386
+ if (!(await fs.lstat(full)).isDirectory())
387
+ fail('NOT_DIRECTORY', 'Expected a directory');
388
+ const entries = [];
389
+ const names = (await fs.readdir(full, { withFileTypes: true }))
390
+ .filter((entry) => entry.isFile() ||
391
+ entry.isDirectory() ||
392
+ entry.isSymbolicLink())
393
+ .map((entry) => entry.name)
394
+ .filter((name) => !name.startsWith('.staff-write-') &&
395
+ !this.privateDirectories.some((directory) => inside(directory, path.join(full, name))))
396
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
397
+ const listingVersion = createHash('sha256')
398
+ .update(JSON.stringify(names))
399
+ .digest('hex');
400
+ let offset = 0;
401
+ if (cursor !== undefined) {
402
+ try {
403
+ const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
404
+ if (value.rootId !== rootId ||
405
+ value.path !== file ||
406
+ value.version !== listingVersion ||
407
+ !Number.isSafeInteger(value.offset) ||
408
+ value.offset < 0 ||
409
+ value.offset > names.length)
410
+ fail('INVALID_CURSOR', 'Directory changed or cursor is invalid; refresh');
411
+ offset = value.offset;
412
+ }
413
+ catch {
414
+ fail('INVALID_CURSOR', 'Directory changed or cursor is invalid; refresh');
415
+ }
416
+ }
417
+ for (const name of names.slice(offset, offset + limit)) {
418
+ const child = file ? `${file}/${name}` : name;
419
+ // Some Unix names (e.g. a literal backslash) cannot be represented by
420
+ // our relative-path contract. Do not let one such entry hide siblings.
421
+ try {
422
+ relativePath(child);
423
+ }
424
+ catch (error) {
425
+ if (error instanceof FileError && error.code === 'OUTSIDE_ROOT')
426
+ continue;
427
+ throw error;
428
+ }
429
+ let st;
430
+ try {
431
+ const target = await this.resolve(rootId, child, {
432
+ allowLeafLink: true,
433
+ });
434
+ st = await fs.lstat(target);
435
+ }
436
+ catch (error) {
437
+ // A listed entry may disappear or become a socket/FIFO before resolution.
438
+ if (error.code === 'ENOENT' ||
439
+ (error instanceof FileError && error.code === 'NOT_FILE'))
440
+ continue;
441
+ throw error;
442
+ }
443
+ if (!st.isFile() && !st.isDirectory() && !st.isSymbolicLink())
444
+ continue;
445
+ entries.push({
446
+ name,
447
+ path: child,
448
+ type: st.isSymbolicLink()
449
+ ? 'symlink'
450
+ : st.isDirectory()
451
+ ? 'directory'
452
+ : 'file',
453
+ size: st.size,
454
+ mtime: st.mtime.toISOString(),
455
+ });
456
+ }
457
+ const next = offset + limit;
458
+ return {
459
+ entries,
460
+ ...(next < names.length
461
+ ? {
462
+ nextCursor: Buffer.from(JSON.stringify({
463
+ rootId,
464
+ path: file,
465
+ offset: next,
466
+ version: listingVersion,
467
+ })).toString('base64url'),
468
+ }
469
+ : {}),
470
+ };
471
+ }
472
+ async readBytes(rootId, file, max = TEXT_LIMIT) {
473
+ const full = await this.resolve(rootId, file);
474
+ const handle = await fs.open(full, constants.O_RDONLY | constants.O_NOFOLLOW);
475
+ try {
476
+ const before = await handle.stat();
477
+ if (!before.isFile())
478
+ fail('NOT_FILE', 'Expected a regular file');
479
+ if (before.size > max)
480
+ fail('TOO_LARGE', `Read limit is ${max} bytes`);
481
+ const data = Buffer.alloc(before.size);
482
+ let offset = 0;
483
+ while (offset < data.length) {
484
+ const { bytesRead } = await handle.read(data, offset, data.length - offset, offset);
485
+ if (!bytesRead)
486
+ fail('VERSION_CONFLICT', 'File changed while reading');
487
+ offset += bytesRead;
488
+ }
489
+ const after = await handle.stat();
490
+ if (after.size !== before.size ||
491
+ after.mtimeMs !== before.mtimeMs ||
492
+ after.ctimeMs !== before.ctimeMs)
493
+ fail('VERSION_CONFLICT', 'File changed while reading');
494
+ return data;
495
+ }
496
+ finally {
497
+ await handle.close();
498
+ }
499
+ }
500
+ async readText(rootId, file, max = TEXT_LIMIT) {
501
+ const data = await this.readBytes(rootId, file, max);
502
+ return {
503
+ text: utf8(data),
504
+ encoding: 'utf-8',
505
+ version: createHash('sha256').update(data).digest('hex'),
506
+ size: data.length,
507
+ };
508
+ }
509
+ async checkDestination(rootId, file, overwrite = false, expectedVersion) {
510
+ if (!relativePath(file))
511
+ fail('ROOT_PROTECTED', 'Cannot replace a root');
512
+ const full = await this.resolve(rootId, file, {
513
+ write: true,
514
+ missing: true,
515
+ });
516
+ let st;
517
+ try {
518
+ st = await fs.lstat(full);
519
+ }
520
+ catch (e) {
521
+ if (e.code !== 'ENOENT')
522
+ throw e;
523
+ }
524
+ if (st) {
525
+ if (!st.isFile())
526
+ fail('NOT_FILE', 'Destination is not a regular file');
527
+ if (!overwrite)
528
+ fail('ALREADY_EXISTS', 'Destination already exists');
529
+ if (expectedVersion === undefined)
530
+ fail('VERSION_REQUIRED', 'Overwriting requires expectedVersion');
531
+ if ((await this.version(full)) !== expectedVersion)
532
+ fail('VERSION_CONFLICT', 'File changed; reload before saving');
533
+ }
534
+ else if (expectedVersion !== undefined)
535
+ fail('VERSION_CONFLICT', 'Expected file no longer exists');
536
+ return { full, exists: !!st, mode: st?.mode };
537
+ }
538
+ async atomic(rootId, file, bytesOrSource, overwrite, expectedVersion, sourceVersion) {
539
+ if (!relativePath(file))
540
+ fail('ROOT_PROTECTED', 'Cannot replace a root');
541
+ // Lease the temporary's ancestry before releasing the shared write lock.
542
+ // Controlled move/delete must not relocate it while the large copy is unlocked.
543
+ const { full, temporary } = await this.mutex.run(async () => {
544
+ const full = await this.resolve(rootId, file, {
545
+ write: true,
546
+ missing: true,
547
+ });
548
+ const temporary = path.join(path.dirname(full), `.staff-write-${randomUUID()}`);
549
+ this.publications.add(temporary);
550
+ return { full, temporary };
551
+ });
552
+ try {
553
+ // Potentially large copy/hash is private staging, not shared publication work.
554
+ if (typeof bytesOrSource === 'string')
555
+ await fs.copyFile(bytesOrSource, temporary, constants.COPYFILE_EXCL);
556
+ else {
557
+ const handle = await fs.open(temporary, constants.O_WRONLY |
558
+ constants.O_CREAT |
559
+ constants.O_EXCL |
560
+ constants.O_NOFOLLOW, 0o600);
561
+ try {
562
+ await handle.writeFile(bytesOrSource);
563
+ await handle.sync();
564
+ }
565
+ finally {
566
+ await handle.close();
567
+ }
568
+ }
569
+ const version = typeof bytesOrSource === 'string'
570
+ ? (sourceVersion ?? (await this.version(temporary)))
571
+ : createHash('sha256').update(bytesOrSource).digest('hex');
572
+ return await this.mutex.run(async () => {
573
+ const destination = await this.checkDestination(rootId, file, overwrite, expectedVersion);
574
+ if (destination.full !== full)
575
+ fail('VERSION_CONFLICT', 'Destination changed');
576
+ // Atomic replacement must retain executable and other permission bits.
577
+ if (destination.mode !== undefined)
578
+ await fs.chmod(temporary, destination.mode & 0o7777);
579
+ if (destination.exists)
580
+ await fs.rename(temporary, full);
581
+ else {
582
+ await fs.link(temporary, full);
583
+ await fs.unlink(temporary);
584
+ }
585
+ this.changed(rootId, file);
586
+ return {
587
+ path: file,
588
+ version,
589
+ size: (await fs.lstat(full)).size,
590
+ };
591
+ });
592
+ }
593
+ finally {
594
+ await this.mutex.run(async () => {
595
+ try {
596
+ await fs.unlink(temporary).catch(() => { });
597
+ }
598
+ finally {
599
+ this.publications.delete(temporary);
600
+ }
601
+ });
602
+ }
603
+ }
604
+ async saveText(rootId, file, text, expectedVersion) {
605
+ const bytes = Buffer.from(text, 'utf8');
606
+ if (bytes.length > TEXT_LIMIT)
607
+ fail('TOO_LARGE', 'Text editing limit is 2 MiB');
608
+ return this.atomic(rootId, file, bytes, true, expectedVersion);
609
+ }
610
+ async createFile(rootId, file, text = '') {
611
+ const bytes = Buffer.from(text, 'utf8');
612
+ if (bytes.length > TEXT_LIMIT)
613
+ fail('TOO_LARGE', 'Text editing limit is 2 MiB');
614
+ return this.atomic(rootId, file, bytes, false);
615
+ }
616
+ async commitUpload(rootId, file, source, overwrite, expectedVersion, sourceVersion) {
617
+ return this.atomic(rootId, file, source, overwrite, expectedVersion, sourceVersion);
618
+ }
619
+ async mkdir(rootId, file) {
620
+ return this.mutex.run(async () => {
621
+ if (!relativePath(file))
622
+ fail('ROOT_PROTECTED', 'Cannot create root');
623
+ const full = await this.resolve(rootId, file, {
624
+ write: true,
625
+ missing: true,
626
+ });
627
+ await fs.mkdir(full);
628
+ this.changed(rootId, file);
629
+ return { path: file, version: await this.version(full) };
630
+ });
631
+ }
632
+ async move(rootId, from, to, expectedVersion) {
633
+ return this.mutex.run(async () => {
634
+ if (!relativePath(from) || !relativePath(to))
635
+ fail('ROOT_PROTECTED', 'Cannot move a root');
636
+ const source = await this.resolve(rootId, from, { write: true });
637
+ this.protectRoots(source);
638
+ const target = await this.resolve(rootId, to, {
639
+ write: true,
640
+ missing: true,
641
+ });
642
+ if (expectedVersion !== undefined &&
643
+ (await this.version(source)) !== expectedVersion)
644
+ fail('VERSION_CONFLICT', 'Source changed');
645
+ if (inside(source, target))
646
+ fail('INVALID_PARAMS', 'Cannot move a directory into itself');
647
+ try {
648
+ await fs.lstat(target);
649
+ fail('ALREADY_EXISTS', 'Destination exists');
650
+ }
651
+ catch (e) {
652
+ if (e.code !== 'ENOENT')
653
+ throw e;
654
+ }
655
+ await this.validateTree(source);
656
+ // All controlled writes use this mutex. POSIX rename cannot provide renameat2(NOREPLACE) against hostile external writers.
657
+ await fs.rename(source, target);
658
+ this.changed(rootId, from);
659
+ this.changed(rootId, to);
660
+ return { path: to, version: await this.version(target) };
661
+ });
662
+ }
663
+ async validateTree(full) {
664
+ const st = await fs.lstat(full);
665
+ if (st.isSymbolicLink())
666
+ fail('SYMLINK_NOT_ALLOWED', 'Operation on a tree containing links is refused');
667
+ if (st.isDirectory())
668
+ for (const child of await fs.readdir(full))
669
+ await this.validateTree(path.join(full, child));
670
+ else if (!st.isFile())
671
+ fail('NOT_FILE', 'Special filesystem objects are not supported');
672
+ }
673
+ async delete(rootId, file, recursive = false, expectedVersion) {
674
+ return this.mutex.run(async () => {
675
+ if (!relativePath(file))
676
+ fail('ROOT_PROTECTED', 'Cannot delete root');
677
+ const full = await this.resolve(rootId, file, { write: true });
678
+ this.protectRoots(full);
679
+ if (expectedVersion !== undefined &&
680
+ (await this.version(full)) !== expectedVersion)
681
+ fail('VERSION_CONFLICT', 'File changed');
682
+ const st = await fs.lstat(full);
683
+ await this.validateTree(full);
684
+ if (st.isDirectory()) {
685
+ if (!recursive)
686
+ await fs.rmdir(full);
687
+ else
688
+ await fs.rm(full, { recursive: true });
689
+ }
690
+ else
691
+ await fs.unlink(full);
692
+ this.changed(rootId, file);
693
+ return { deleted: true, path: file };
694
+ });
695
+ }
696
+ async snapshot(rootId, file, destination) {
697
+ const source = await this.resolve(rootId, file);
698
+ const input = await fs.open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
699
+ let output;
700
+ try {
701
+ const before = await input.stat();
702
+ if (!before.isFile())
703
+ fail('NOT_FILE', 'Only files can be downloaded');
704
+ if (before.size > FILE_LIMIT)
705
+ fail('TOO_LARGE', 'Download limit is 100 MiB');
706
+ output = await fs.open(destination, constants.O_WRONLY |
707
+ constants.O_CREAT |
708
+ constants.O_EXCL |
709
+ constants.O_NOFOLLOW, 0o600);
710
+ const hash = createHash('sha256');
711
+ const buffer = Buffer.alloc(CHUNK_LIMIT);
712
+ let size = 0;
713
+ while (true) {
714
+ const { bytesRead } = await input.read(buffer, 0, Math.min(buffer.length, FILE_LIMIT + 1 - size), size);
715
+ if (!bytesRead)
716
+ break;
717
+ size += bytesRead;
718
+ if (size > FILE_LIMIT)
719
+ fail('TOO_LARGE', 'Download limit is 100 MiB');
720
+ const chunk = buffer.subarray(0, bytesRead);
721
+ hash.update(chunk);
722
+ await output.writeFile(chunk);
723
+ }
724
+ const unchanged = (after) => before.dev === after.dev &&
725
+ before.ino === after.ino &&
726
+ before.size === after.size &&
727
+ before.mtimeMs === after.mtimeMs &&
728
+ before.ctimeMs === after.ctimeMs;
729
+ // A single-pass hash covers the exact snapshot bytes. Metadata and identity checks
730
+ // detect replacement/in-place changes, without hashing the source two more times.
731
+ if (size !== before.size || !unchanged(await input.stat()))
732
+ fail('VERSION_CONFLICT', 'Source changed while creating download snapshot');
733
+ await this.mutex.run(async () => {
734
+ const current = await this.resolve(rootId, file);
735
+ if (current !== source || !unchanged(await fs.lstat(current)))
736
+ fail('VERSION_CONFLICT', 'Source changed while creating download snapshot');
737
+ });
738
+ return {
739
+ name: path.basename(file),
740
+ size,
741
+ version: hash.digest('hex'),
742
+ };
743
+ }
744
+ finally {
745
+ await input.close();
746
+ await output?.close();
747
+ }
748
+ }
749
+ }
750
+ //# sourceMappingURL=file-core.js.map