dsh-plugin-dshm 0.0.0-stage → 0.1.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 (80) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +228 -2
  4. package/README.zh-CN.md +190 -0
  5. package/cordis.patch.yml +7 -0
  6. package/lib/auth/signature.d.ts +4 -0
  7. package/lib/auth/signature.js +24 -0
  8. package/lib/cli/index.d.ts +4 -0
  9. package/lib/cli/index.js +41 -0
  10. package/lib/client.js +419 -0
  11. package/lib/config.d.ts +10 -0
  12. package/lib/config.js +27 -0
  13. package/lib/discovery/advertiser.d.ts +45 -0
  14. package/lib/discovery/advertiser.js +141 -0
  15. package/lib/discovery/interfaces.d.ts +5 -0
  16. package/lib/discovery/interfaces.js +24 -0
  17. package/lib/errors.d.ts +5 -0
  18. package/lib/errors.js +10 -0
  19. package/lib/host/local.d.ts +5 -0
  20. package/lib/host/local.js +122 -0
  21. package/lib/host/tailscale.d.ts +43 -0
  22. package/lib/host/tailscale.js +137 -0
  23. package/lib/host/types.d.ts +40 -0
  24. package/lib/host/types.js +1 -0
  25. package/lib/index.d.ts +30 -0
  26. package/lib/index.js +98 -0
  27. package/lib/methods/index.d.ts +9 -0
  28. package/lib/methods/index.js +206 -0
  29. package/lib/pairing/state.d.ts +26 -0
  30. package/lib/pairing/state.js +89 -0
  31. package/lib/pairing/window.d.ts +18 -0
  32. package/lib/pairing/window.js +57 -0
  33. package/lib/projection/client.d.mts +2 -0
  34. package/lib/projection/client.mjs +11 -0
  35. package/lib/projection/files.d.ts +33 -0
  36. package/lib/projection/files.js +278 -0
  37. package/lib/projection/fold-recording.d.mts +50 -0
  38. package/lib/projection/fold-recording.mjs +440 -0
  39. package/lib/projection/host.d.ts +6 -0
  40. package/lib/projection/host.js +86 -0
  41. package/lib/projection/ids.d.mts +1 -0
  42. package/lib/projection/ids.mjs +1 -0
  43. package/lib/projection/index.d.ts +2 -0
  44. package/lib/projection/index.js +2 -0
  45. package/lib/projection/interactions.d.ts +10 -0
  46. package/lib/projection/interactions.js +73 -0
  47. package/lib/projection/native.d.ts +114 -0
  48. package/lib/projection/native.js +903 -0
  49. package/lib/protocol/THIRD_PARTY_LICENSES.txt +135 -0
  50. package/lib/protocol/dmp.d.ts +402 -0
  51. package/lib/protocol/dmp.js +1 -0
  52. package/lib/protocol/schema/dmp-v1/common.schema.json +88 -0
  53. package/lib/protocol/schema/dmp-v1/events.schema.json +107 -0
  54. package/lib/protocol/schema/dmp-v1/frames.schema.json +280 -0
  55. package/lib/protocol/schema/dmp-v1/methods.schema.json +584 -0
  56. package/lib/protocol/schema/dmp-v1/models.schema.json +674 -0
  57. package/lib/protocol/scripts/contract.mjs +8106 -0
  58. package/lib/push/controller.d.ts +64 -0
  59. package/lib/push/controller.js +290 -0
  60. package/lib/push/observer.d.ts +51 -0
  61. package/lib/push/observer.js +266 -0
  62. package/lib/push/payload.d.ts +15 -0
  63. package/lib/push/payload.js +18 -0
  64. package/lib/push/store.d.ts +17 -0
  65. package/lib/push/store.js +75 -0
  66. package/lib/server/index.d.ts +30 -0
  67. package/lib/server/index.js +500 -0
  68. package/lib/server/rate-limit.d.ts +7 -0
  69. package/lib/server/rate-limit.js +19 -0
  70. package/lib/server/uploads.d.ts +21 -0
  71. package/lib/server/uploads.js +74 -0
  72. package/lib/types.d.ts +92 -0
  73. package/lib/types.js +1 -0
  74. package/lib/ui/card.d.mts +3 -0
  75. package/lib/ui/card.mjs +59 -0
  76. package/lib/ui/copy.d.mts +146 -0
  77. package/lib/ui/copy.mjs +106 -0
  78. package/lib/ui/remote-setup.d.mts +81 -0
  79. package/lib/ui/remote-setup.mjs +75 -0
  80. package/package.json +67 -4
@@ -0,0 +1,278 @@
1
+ import { lstat, realpath } from 'node:fs/promises';
2
+ import { basename, isAbsolute, relative, resolve, sep, win32 } from 'node:path';
3
+ import { DmpError } from '../errors.js';
4
+ import { deliverableId } from './ids.mjs';
5
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
6
+ const array = (value) => Array.isArray(value) ? value : [];
7
+ const string = (value) => typeof value === 'string' ? value : '';
8
+ const integer = (value, fallback = 0) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : fallback;
9
+ const DOWNLOAD_LIMIT = 32 * 1024 * 1024;
10
+ const TEXT_LIMIT = 1024 * 1024;
11
+ // Leave room for the response envelope, including the largest request id.
12
+ const RESULT_LIMIT = TEXT_LIMIT - 1024;
13
+ const size = (value) => Buffer.byteLength(JSON.stringify(value));
14
+ const kind = (value) => value.isSymbolicLink() ? 'symlink' : value.isDirectory() ? 'dir' : value.isFile() ? 'file' : 'other';
15
+ const events = (session) => session.records.filter(record => record.source === 'session').map(record => object(record.frame.event));
16
+ function fileError(error) {
17
+ if (error instanceof DmpError)
18
+ throw error;
19
+ const code = string(object(error).code), name = string(object(error).name);
20
+ if (name === 'AbortError')
21
+ throw new DmpError('cancelled');
22
+ if (name === 'TimeoutError')
23
+ throw new DmpError('timeout');
24
+ if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(code) || code.includes('not-found') || code.includes('not-regular-file'))
25
+ throw new DmpError('not-found');
26
+ if (['EACCES', 'EPERM'].includes(code) || code.includes('outside-workspace'))
27
+ throw new DmpError('forbidden-path');
28
+ if (code.includes('not-text'))
29
+ throw new DmpError('invalid-params', 'File is not text', { reason: 'not-text' });
30
+ if (code.includes('too-large'))
31
+ throw new DmpError('too-large', 'File exceeds the transfer limit', { limit: DOWNLOAD_LIMIT });
32
+ if (code.includes('bad-request') || code.includes('not-directory'))
33
+ throw new DmpError('invalid-params');
34
+ if (code.includes('peer-disposed'))
35
+ throw new DmpError('dsh-unavailable');
36
+ throw new DmpError('internal', 'DSH file operation failed');
37
+ }
38
+ function requestedPath(value) {
39
+ if (typeof value !== 'string')
40
+ throw new DmpError('invalid-params');
41
+ // DMP paths use '/'. Reject Windows syntax on every OS before touching any
42
+ // native file method, so a macOS-tested client cannot become a Windows escape.
43
+ if (value.includes('\0') || value.includes('\\') || isAbsolute(value) || win32.isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.split('/').includes('..'))
44
+ throw new DmpError('forbidden-path');
45
+ return value || '.';
46
+ }
47
+ function within(root, target) {
48
+ const path = relative(root, target);
49
+ return path === '' || path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path);
50
+ }
51
+ function budget(result) {
52
+ if (size(result) > RESULT_LIMIT)
53
+ throw new DmpError('too-large', 'Result exceeds the text frame limit', { limit: TEXT_LIMIT });
54
+ return result;
55
+ }
56
+ export class FileProjection {
57
+ access;
58
+ constructor(access) {
59
+ this.access = access;
60
+ }
61
+ allowed(path, home, root) {
62
+ // Check both configured and real home paths: a home can itself be a symlink.
63
+ // This deny rule also wins over the declared-deliverable workspace exception.
64
+ if (within(home, path) || within(resolve(this.access.home), path) || root !== undefined && !within(root, path))
65
+ throw new DmpError('forbidden-path');
66
+ }
67
+ async target(sessionId, input, signal) {
68
+ const path = requestedPath(input);
69
+ const session = await this.access.session(sessionId, signal);
70
+ if (!session.cwd || !isAbsolute(session.cwd))
71
+ throw new DmpError('dsh-incompatible', 'Session has no workspace root');
72
+ try {
73
+ signal.throwIfAborted();
74
+ const home = await realpath(this.access.home), root = await realpath(session.cwd), requested = resolve(root, path);
75
+ this.allowed(requested, home, root);
76
+ const absolute = await realpath(requested);
77
+ this.allowed(absolute, home, root);
78
+ return { path, requested, absolute, home, root, rootPath: session.cwd, stats: await lstat(requested) };
79
+ }
80
+ catch (error) {
81
+ return fileError(error);
82
+ }
83
+ }
84
+ async declaredTarget(cwd, path) {
85
+ try {
86
+ const home = await realpath(this.access.home), requested = resolve(cwd, path);
87
+ this.allowed(requested, home);
88
+ const absolute = await realpath(requested);
89
+ this.allowed(absolute, home);
90
+ return { path, requested, absolute, home, stats: await lstat(requested) };
91
+ }
92
+ catch (error) {
93
+ return fileError(error);
94
+ }
95
+ }
96
+ async recheck(target) {
97
+ try {
98
+ const home = await realpath(this.access.home);
99
+ const [requested, absolute, root] = await Promise.all([
100
+ realpath(target.requested), realpath(target.absolute), target.rootPath ? realpath(target.rootPath) : undefined,
101
+ ]);
102
+ if (home !== target.home || requested !== target.absolute || absolute !== target.absolute || root !== target.root)
103
+ throw new DmpError('forbidden-path');
104
+ this.allowed(requested, home, root);
105
+ }
106
+ catch {
107
+ throw new DmpError('forbidden-path', 'File path changed during the operation');
108
+ }
109
+ }
110
+ async guardedCall(method, sessionId, target, extra, signal) {
111
+ // Native reads reopen a pathname. Verify resolution again before releasing
112
+ // anything to DMP, including metadata, and discard results after a swap.
113
+ try {
114
+ return await this.call(method, sessionId, target.absolute, extra, signal);
115
+ }
116
+ finally {
117
+ await this.recheck(target);
118
+ }
119
+ }
120
+ async call(method, sessionId, path, extra, signal) {
121
+ try {
122
+ return object(await this.access.call('workspaceFiles', method, { workspaceFileScopeId: sessionId, path, ...extra }, signal));
123
+ }
124
+ catch (error) {
125
+ return fileError(error);
126
+ }
127
+ }
128
+ async request(method, params, signal) {
129
+ signal.throwIfAborted();
130
+ if (method.startsWith('changes.'))
131
+ return this.changes(method, params, signal);
132
+ if (method === 'deliverables.list') {
133
+ const sessionId = string(params.sessionId), session = await this.access.session(sessionId, signal);
134
+ return budget({ deliverables: events(session).filter(event => event.type === 'deliverables/presented').flatMap(event => array(object(event.data).files).flatMap((raw, index) => {
135
+ const file = object(raw), path = string(file.path);
136
+ if (!path)
137
+ return [];
138
+ return [{ deliverableId: deliverableId(sessionId, integer(event.seq), index), name: basename(path.replaceAll('\\', '/')), path, ...(typeof file.description === 'string' ? { description: file.description } : {}), turn: integer(object(event.data).turn), presentedAt: new Date(typeof event.time === 'number' || typeof event.time === 'string' ? event.time : 0).toISOString() }];
139
+ })) });
140
+ }
141
+ if (!['files.list', 'files.read', 'files.stat'].includes(method))
142
+ throw new DmpError('unsupported');
143
+ const sessionId = string(params.sessionId), target = await this.target(sessionId, params.path, signal);
144
+ if (method === 'files.list') {
145
+ const result = await this.guardedCall('list', sessionId, target, {}, signal);
146
+ return budget({ path: target.path, entries: array(result.entries).map(raw => {
147
+ const entry = object(raw);
148
+ return { name: string(entry.name), type: ['file', 'dir', 'symlink', 'other'].includes(string(entry.type)) ? entry.type : 'other', ...(typeof entry.size === 'number' ? { size: integer(entry.size) } : {}) };
149
+ }), truncated: Boolean(result.truncated) });
150
+ }
151
+ if (method === 'files.stat') {
152
+ // Native stat exposes only file versions. Directories and symlinks still
153
+ // have useful DMP stat results; their opaque version describes that node.
154
+ const result = target.stats.isFile() ? await this.guardedCall('stat', sessionId, target, {}, signal) : {};
155
+ await this.recheck(target);
156
+ return { type: kind(target.stats), size: target.stats.size, version: string(result.version) || `${target.stats.dev}:${target.stats.ino}:${target.stats.mtimeMs}:${target.stats.size}` };
157
+ }
158
+ // The DMP caller may request 5,000 lines, but the tested native version
159
+ // accepts at most 2,000. A shorter page preserves the continuation contract.
160
+ let limit = Math.min(2000, Math.max(1, integer(params.limit, 2000))), offset = Math.max(1, integer(params.offset, 1));
161
+ for (;;) {
162
+ let native;
163
+ try {
164
+ native = await this.guardedCall('read', sessionId, target, { range: { offset, limit } }, signal);
165
+ }
166
+ catch (error) {
167
+ if (error instanceof DmpError && error.code === 'too-large' && limit > 1) {
168
+ limit = Math.max(1, Math.floor(limit / 2));
169
+ continue;
170
+ }
171
+ if (error instanceof DmpError && error.code === 'too-large')
172
+ throw new DmpError('too-large', 'A line exceeds the text frame limit', { limit: TEXT_LIMIT });
173
+ throw error;
174
+ }
175
+ const result = { path: target.path, text: string(native.text), offset: integer(native.offset, offset), lines: integer(native.lines), eof: Boolean(native.eof), version: string(native.version) };
176
+ if (size(result) <= RESULT_LIMIT)
177
+ return result;
178
+ // Retry the native line read, rather than slicing text and losing line or
179
+ // EOF semantics. JSON escapes count against the frame budget too.
180
+ if (result.lines <= 1 || limit <= 1)
181
+ throw new DmpError('too-large', 'A line exceeds the text frame limit', { limit: TEXT_LIMIT });
182
+ limit = Math.max(1, Math.min(limit - 1, Math.floor(result.lines / 2)));
183
+ }
184
+ }
185
+ async content(method, params, signal) {
186
+ signal.throwIfAborted();
187
+ let sessionId, target;
188
+ if (method === 'files.download') {
189
+ sessionId = string(params.sessionId);
190
+ target = await this.target(sessionId, params.path, signal);
191
+ }
192
+ else if (method === 'deliverables.download') {
193
+ const match = /^d\.([A-Za-z0-9_-]+)\.(\d+)\.(\d+)$/.exec(string(params.deliverableId));
194
+ if (!match)
195
+ throw new DmpError('not-found');
196
+ sessionId = Buffer.from(match[1], 'base64url').toString('utf8');
197
+ const seq = Number(match[2]), index = Number(match[3]);
198
+ if (!sessionId || !Number.isSafeInteger(seq) || !Number.isSafeInteger(index) || deliverableId(sessionId, seq, index) !== params.deliverableId)
199
+ throw new DmpError('not-found');
200
+ const session = await this.access.session(sessionId, signal);
201
+ const event = events(session).find(event => event.seq === seq && event.type === 'deliverables/presented');
202
+ const declared = string(object(array(object(event?.data).files)[index]).path);
203
+ if (!declared)
204
+ throw new DmpError('not-found');
205
+ target = await this.declaredTarget(session.cwd, declared);
206
+ }
207
+ else
208
+ throw new DmpError('unsupported');
209
+ try {
210
+ const stats = method === 'files.download' ? await lstat(target.absolute) : target.stats;
211
+ if (!stats.isFile())
212
+ throw new DmpError('not-found');
213
+ if (stats.size > DOWNLOAD_LIMIT)
214
+ throw new DmpError('too-large', 'File exceeds the transfer limit', { limit: DOWNLOAD_LIMIT });
215
+ const result = await this.guardedCall('readBytes', sessionId, target, { options: {} }, signal);
216
+ if (!(result.data instanceof Uint8Array))
217
+ throw new DmpError('dsh-incompatible', 'Native file bytes are unavailable');
218
+ if (result.data.byteLength > DOWNLOAD_LIMIT)
219
+ throw new DmpError('too-large', 'File exceeds the transfer limit', { limit: DOWNLOAD_LIMIT });
220
+ if (result.eof !== true)
221
+ throw new DmpError('dsh-incompatible', 'Native full-file read returned partial bytes');
222
+ return result.data;
223
+ }
224
+ catch (error) {
225
+ return fileError(error);
226
+ }
227
+ }
228
+ async changeFetch(route, sessionId, seq, signal, index) {
229
+ const url = new URL(`/api/${route}`, 'http://localhost');
230
+ url.search = new URLSearchParams({ sessionId, seq: String(seq), ...(index !== undefined ? { index: String(index) } : {}) }).toString();
231
+ let response;
232
+ try {
233
+ response = await this.access.fetch(new Request(url, { signal }));
234
+ }
235
+ catch (error) {
236
+ return fileError(error);
237
+ }
238
+ if (response.status === 404) {
239
+ this.access.changeState?.(sessionId, seq, { available: false });
240
+ throw new DmpError('expired', 'Changed-file snapshot is no longer available');
241
+ }
242
+ if (response.status === 400)
243
+ throw new DmpError('invalid-params');
244
+ if (!response.ok)
245
+ throw new DmpError('dsh-unavailable', 'Changed-file snapshot could not be read');
246
+ return object(await response.json());
247
+ }
248
+ async changes(method, params, signal) {
249
+ const sessionId = string(params.sessionId), session = await this.access.session(sessionId, signal);
250
+ const match = /^change-(\d+)$/.exec(string(params.changeId)), seq = match ? Number(match[1]) : -1;
251
+ if (!Number.isSafeInteger(seq) || !events(session).some(event => event.seq === seq && event.type === 'workspace/changes'))
252
+ throw new DmpError('not-found');
253
+ const summary = await this.changeFetch('changes.summary', sessionId, seq, signal), files = array(summary.files).map(object);
254
+ this.access.changeState?.(sessionId, seq, { available: true, fileCount: files.length, added: integer(summary.added), deleted: integer(summary.deleted) });
255
+ if (method === 'changes.summary') {
256
+ const projected = [];
257
+ for (let index = 0; index < files.length; index++) {
258
+ const file = files[index], diff = await this.changeFetch('changes.diff', sessionId, seq, signal, index);
259
+ projected.push({ index, path: string(file.path), display: string(file.display || file.path).replaceAll('\\', '/'), status: diff.before === false ? 'added' : diff.after === false ? 'deleted' : 'modified', added: integer(file.added), deleted: integer(file.deleted) });
260
+ }
261
+ return budget({ files: projected, added: integer(summary.added), deleted: integer(summary.deleted) });
262
+ }
263
+ if (method !== 'changes.diff')
264
+ throw new DmpError('unsupported');
265
+ const index = integer(params.index, -1);
266
+ if (index < 0 || index >= files.length)
267
+ throw new DmpError('not-found');
268
+ const native = await this.changeFetch('changes.diff', sessionId, seq, signal, index), path = string(native.path);
269
+ if (native.kind === 'binary' || native.kind === 'oversized')
270
+ return { diff: { kind: native.kind, path } };
271
+ if (native.kind !== 'text')
272
+ throw new DmpError('dsh-incompatible', 'Unknown changed-file comparison');
273
+ return budget({ diff: { kind: 'text', path, hunks: array(native.hunks).map(raw => {
274
+ const hunk = object(raw);
275
+ return { oldStart: integer(hunk.oldStart), oldLines: integer(hunk.oldLines), newStart: integer(hunk.newStart), newLines: integer(hunk.newLines), lines: array(hunk.lines).map(string) };
276
+ }), coarse: Boolean(native.coarse) } });
277
+ }
278
+ }
@@ -0,0 +1,50 @@
1
+ export function createProjectionFold(metadata: any, options?: {}): {
2
+ accept(record: any): any[];
3
+ state: () => {
4
+ session: {
5
+ permission?: {
6
+ current: any;
7
+ } | undefined;
8
+ cwd?: any;
9
+ summary: any;
10
+ };
11
+ items: any[];
12
+ inlineItems: any[];
13
+ interactions: any[];
14
+ queue: any[];
15
+ throughSeq: number;
16
+ removed: [any, any][];
17
+ };
18
+ result: () => {
19
+ transcript: {
20
+ description: string;
21
+ frames: any[];
22
+ };
23
+ replay: any[];
24
+ mock: {
25
+ files: {
26
+ path: string;
27
+ mime: string;
28
+ }[];
29
+ changes: never[];
30
+ jobs: never[];
31
+ jobOutput: never[];
32
+ };
33
+ };
34
+ };
35
+ export function foldRecording(records: any, metadata: any): {
36
+ transcript: {
37
+ description: string;
38
+ frames: any[];
39
+ };
40
+ replay: any[];
41
+ mock: {
42
+ files: {
43
+ path: string;
44
+ mime: string;
45
+ }[];
46
+ changes: never[];
47
+ jobs: never[];
48
+ jobOutput: never[];
49
+ };
50
+ };