@myagentroam/node 0.9.69 → 0.9.71
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/capabilities.js +3 -2
- package/dist/connector.js +2 -3
- package/dist/service/direct-transfer-service.js +223 -157
- package/dist/service/workspace-file-service.js +40 -26
- package/dist/service/workspace-upload-service.js +5 -26
- package/dist/service/workspace-workbench-service.js +59 -27
- package/dist/workspace.js +7 -0
- package/package.json +3 -3
package/dist/capabilities.js
CHANGED
|
@@ -11,10 +11,11 @@ const runtimeCredentialCapability = () => ({
|
|
|
11
11
|
httpVersions: ['1.1', '2']
|
|
12
12
|
});
|
|
13
13
|
const workspaceDirectTransferCapability = () => ({
|
|
14
|
-
apiVersion:
|
|
15
|
-
|
|
14
|
+
apiVersion: 3,
|
|
15
|
+
transport: true,
|
|
16
16
|
upload: true,
|
|
17
17
|
download: true,
|
|
18
|
+
range: true,
|
|
18
19
|
gitCommitBlob: true,
|
|
19
20
|
maxChunkBytes: 256 * 1024
|
|
20
21
|
});
|
package/dist/connector.js
CHANGED
|
@@ -1395,13 +1395,12 @@ export class NodeConnector {
|
|
|
1395
1395
|
send: (type, payload) => this.send(type, payload),
|
|
1396
1396
|
udpPortRange: () => directTransferUdpPortRange(this.config),
|
|
1397
1397
|
openUpload: async (descriptor) => {
|
|
1398
|
-
if (descriptor.resource.kind !== 'WORKSPACE_FILE')
|
|
1398
|
+
if (descriptor.direction !== 'UPLOAD' || descriptor.resource.kind !== 'WORKSPACE_FILE')
|
|
1399
1399
|
throw new Error('DIRECT_RESOURCE_UNSUPPORTED');
|
|
1400
1400
|
const workspace = this.workspaceCoordinator.require(descriptor.resource.workspaceId);
|
|
1401
1401
|
const upload = await this.workspaceUploadService.createDirect(workspace, {
|
|
1402
1402
|
path: descriptor.resource.path,
|
|
1403
1403
|
size: descriptor.size,
|
|
1404
|
-
sha256: descriptor.sha256,
|
|
1405
1404
|
overwrite: descriptor.overwrite === true,
|
|
1406
1405
|
...(descriptor.composerAttachment === undefined
|
|
1407
1406
|
? {}
|
|
@@ -1410,7 +1409,7 @@ export class NodeConnector {
|
|
|
1410
1409
|
return {
|
|
1411
1410
|
offset: upload.receivedBytes,
|
|
1412
1411
|
write: (bytes) => this.workspaceUploadService.writeDirect(upload.uploadId, bytes),
|
|
1413
|
-
complete: (size
|
|
1412
|
+
complete: (size) => this.workspaceUploadService.completeDirect(upload.uploadId, size),
|
|
1414
1413
|
cancel: () => this.workspaceUploadService.cancel(upload.uploadId)
|
|
1415
1414
|
};
|
|
1416
1415
|
},
|
|
@@ -2,14 +2,16 @@ import { directDataControlFrameSchema, directNodeCommandSchema } from '@myagentr
|
|
|
2
2
|
import { IceUdpMuxListener as NativeIceUdpMuxListener } from 'node-datachannel';
|
|
3
3
|
import { RTCPeerConnection } from 'node-datachannel/polyfill';
|
|
4
4
|
const MAX_HOST_CANDIDATES = 32;
|
|
5
|
-
const
|
|
5
|
+
const MAX_ACTIVE_TRANSPORTS = 128;
|
|
6
|
+
const MAX_ACTIVE_TRANSFERS = 128;
|
|
6
7
|
const DATA_CHANNEL_HIGH_WATER_BYTES = 4 * 1024 * 1024;
|
|
7
8
|
const DATA_CHANNEL_LOW_WATER_BYTES = 1024 * 1024;
|
|
8
9
|
const LEASE_GRACE_MS = 30_000;
|
|
9
10
|
const LEASE_CHECK_MS = 5_000;
|
|
10
11
|
export class DirectTransferService {
|
|
11
12
|
options;
|
|
12
|
-
|
|
13
|
+
transports = new Map();
|
|
14
|
+
transfers = new Map();
|
|
13
15
|
now;
|
|
14
16
|
createPeerConnection;
|
|
15
17
|
iceUdpMuxListener;
|
|
@@ -53,8 +55,10 @@ export class DirectTransferService {
|
|
|
53
55
|
return true;
|
|
54
56
|
}
|
|
55
57
|
close() {
|
|
56
|
-
for (const
|
|
57
|
-
void this.
|
|
58
|
+
for (const transfer of [...this.transfers.values()])
|
|
59
|
+
void this.removeTransfer(transfer, 'DIRECT_NODE_STOPPED');
|
|
60
|
+
for (const transport of [...this.transports.values()])
|
|
61
|
+
this.removeTransport(transport, 'DIRECT_NODE_STOPPED');
|
|
58
62
|
}
|
|
59
63
|
dispose() {
|
|
60
64
|
if (this.disposed)
|
|
@@ -65,51 +69,71 @@ export class DirectTransferService {
|
|
|
65
69
|
}
|
|
66
70
|
async dispatch(command) {
|
|
67
71
|
if (command.type === 'direct.prepare') {
|
|
68
|
-
|
|
72
|
+
if (command.purpose === 'TRANSPORT')
|
|
73
|
+
this.prepareTransport(command);
|
|
74
|
+
else
|
|
75
|
+
this.prepareTransfer(command);
|
|
69
76
|
return;
|
|
70
77
|
}
|
|
71
|
-
const session = this.sessions.get(command.directSessionId);
|
|
72
|
-
if (session === undefined)
|
|
73
|
-
throw new Error('DIRECT_SESSION_INVALID');
|
|
74
78
|
if (command.type === 'direct.signal') {
|
|
75
|
-
|
|
79
|
+
const transport = this.transports.get(command.directSessionId);
|
|
80
|
+
if (transport === undefined)
|
|
81
|
+
throw new Error('DIRECT_TRANSFER_SESSION_NOT_FOUND');
|
|
82
|
+
await this.signal(transport, command.signal);
|
|
76
83
|
return;
|
|
77
84
|
}
|
|
78
85
|
if (command.type === 'direct.grant') {
|
|
79
|
-
|
|
80
|
-
|
|
86
|
+
const transfer = this.transfers.get(command.directSessionId);
|
|
87
|
+
const transport = this.transports.get(command.transportSessionId);
|
|
88
|
+
if (transfer === undefined ||
|
|
89
|
+
transport === undefined ||
|
|
90
|
+
transfer.command.transportSessionId !== command.transportSessionId ||
|
|
91
|
+
command.browserFingerprint !== fingerprint(transport.peer.remoteDescription?.sdp) ||
|
|
92
|
+
command.nodeFingerprint !== fingerprint(transport.peer.localDescription?.sdp) ||
|
|
81
93
|
command.openExpiresAt <= this.now() ||
|
|
82
|
-
command.expiresAt >
|
|
83
|
-
throw new Error('
|
|
84
|
-
|
|
94
|
+
command.expiresAt > transfer.command.expiresAt)
|
|
95
|
+
throw new Error('DIRECT_TRANSFER_GRANT_INVALID');
|
|
96
|
+
transfer.grant = command;
|
|
85
97
|
return;
|
|
86
98
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
99
|
+
const transport = this.transports.get(command.directSessionId);
|
|
100
|
+
if (transport !== undefined) {
|
|
101
|
+
if (command.type === 'direct.heartbeat') {
|
|
102
|
+
transport.lastLeaseAliveAt = this.now();
|
|
103
|
+
transport.leaseExpiresAt = command.expiresAt;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.removeTransport(transport, command.code);
|
|
92
107
|
return;
|
|
93
108
|
}
|
|
94
|
-
|
|
109
|
+
const transfer = this.transfers.get(command.directSessionId);
|
|
110
|
+
if (transfer === undefined)
|
|
111
|
+
throw new Error('DIRECT_TRANSFER_SESSION_NOT_FOUND');
|
|
112
|
+
if (command.type === 'direct.heartbeat') {
|
|
113
|
+
transfer.lastLeaseAliveAt = this.now();
|
|
114
|
+
transfer.leaseExpiresAt = command.expiresAt;
|
|
115
|
+
}
|
|
116
|
+
else
|
|
117
|
+
await this.removeTransfer(transfer, command.code);
|
|
95
118
|
}
|
|
96
|
-
|
|
119
|
+
prepareTransport(command) {
|
|
97
120
|
if (command.nodeGeneration !== this.options.nodeGeneration())
|
|
98
121
|
throw new Error('DIRECT_NODE_GENERATION_MISMATCH');
|
|
99
|
-
if (this.
|
|
100
|
-
throw new Error('
|
|
101
|
-
if (this.
|
|
122
|
+
if (this.transports.has(command.directSessionId) || this.transfers.has(command.directSessionId))
|
|
123
|
+
throw new Error('DIRECT_TRANSFER_SESSION_CONFLICT');
|
|
124
|
+
if (this.transports.size >= MAX_ACTIVE_TRANSPORTS)
|
|
102
125
|
throw new Error('DIRECT_SESSION_LIMIT_REACHED');
|
|
103
126
|
const peer = this.createPeerConnection(this.peerConfiguration);
|
|
104
|
-
const
|
|
127
|
+
const transport = {
|
|
105
128
|
command,
|
|
106
129
|
peer,
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
130
|
+
sentCandidateKeys: new Set(),
|
|
131
|
+
lastLeaseAliveAt: this.now(),
|
|
132
|
+
leaseExpiresAt: command.expiresAt,
|
|
133
|
+
leaseTimer: undefined
|
|
111
134
|
};
|
|
112
|
-
this.
|
|
135
|
+
transport.leaseTimer = this.startLeaseWatch(() => transport.leaseExpiresAt, () => transport.lastLeaseAliveAt, () => this.removeTransport(transport, 'DIRECT_TRANSFER_CONTROL_UNAVAILABLE'));
|
|
136
|
+
this.transports.set(command.directSessionId, transport);
|
|
113
137
|
peer.onicecandidate = (event) => {
|
|
114
138
|
const candidate = event.candidate;
|
|
115
139
|
if (candidate === null) {
|
|
@@ -122,7 +146,7 @@ export class DirectTransferService {
|
|
|
122
146
|
}
|
|
123
147
|
const json = candidate.toJSON();
|
|
124
148
|
if (typeof json.candidate !== 'string' ||
|
|
125
|
-
!acceptHostCandidate(json.candidate,
|
|
149
|
+
!acceptHostCandidate(json.candidate, transport.sentCandidateKeys))
|
|
126
150
|
return;
|
|
127
151
|
this.emit(command.directSessionId, {
|
|
128
152
|
type: 'direct.signal',
|
|
@@ -137,178 +161,194 @@ export class DirectTransferService {
|
|
|
137
161
|
}
|
|
138
162
|
});
|
|
139
163
|
};
|
|
140
|
-
peer.ondatachannel = (event) => this.
|
|
164
|
+
peer.ondatachannel = (event) => this.attachPendingChannel(transport, event.channel);
|
|
141
165
|
peer.onconnectionstatechange = () => {
|
|
142
166
|
if (peer.connectionState === 'connected')
|
|
143
|
-
this.emit(
|
|
167
|
+
this.emit(command.directSessionId, {
|
|
144
168
|
type: 'direct.connected',
|
|
145
|
-
directSessionId:
|
|
169
|
+
directSessionId: command.directSessionId
|
|
146
170
|
});
|
|
147
171
|
if (peer.connectionState === 'failed' || peer.connectionState === 'closed')
|
|
148
|
-
|
|
172
|
+
this.removeTransport(transport, 'DIRECT_CONNECTION_FAILED');
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
prepareTransfer(command) {
|
|
176
|
+
if (command.nodeGeneration !== this.options.nodeGeneration())
|
|
177
|
+
throw new Error('DIRECT_NODE_GENERATION_MISMATCH');
|
|
178
|
+
if (this.transfers.has(command.directSessionId) || this.transports.has(command.directSessionId))
|
|
179
|
+
throw new Error('DIRECT_TRANSFER_SESSION_CONFLICT');
|
|
180
|
+
const transport = this.transports.get(command.transportSessionId);
|
|
181
|
+
if (transport === undefined ||
|
|
182
|
+
transport.command.workbenchConnectionId !== command.workbenchConnectionId ||
|
|
183
|
+
transport.command.nodeGeneration !== command.nodeGeneration ||
|
|
184
|
+
transport.peer.connectionState !== 'connected')
|
|
185
|
+
throw new Error('DIRECT_TRANSFER_UNAVAILABLE');
|
|
186
|
+
if (this.transfers.size >= MAX_ACTIVE_TRANSFERS)
|
|
187
|
+
throw new Error('DIRECT_SESSION_LIMIT_REACHED');
|
|
188
|
+
const transfer = {
|
|
189
|
+
command,
|
|
190
|
+
uploadCommitted: false,
|
|
191
|
+
opened: false,
|
|
192
|
+
processing: Promise.resolve(),
|
|
193
|
+
lastLeaseAliveAt: this.now(),
|
|
194
|
+
leaseExpiresAt: command.expiresAt,
|
|
195
|
+
leaseTimer: undefined
|
|
149
196
|
};
|
|
197
|
+
transfer.leaseTimer = this.startLeaseWatch(() => transfer.leaseExpiresAt, () => transfer.lastLeaseAliveAt, () => void this.removeTransfer(transfer, 'DIRECT_TRANSFER_CONTROL_UNAVAILABLE'));
|
|
198
|
+
this.transfers.set(command.directSessionId, transfer);
|
|
150
199
|
}
|
|
151
|
-
async signal(
|
|
200
|
+
async signal(transport, signal) {
|
|
152
201
|
if (signal.kind === 'CANDIDATE') {
|
|
153
202
|
if (!safeHostCandidate(signal.candidate.candidate))
|
|
154
|
-
throw new Error('
|
|
155
|
-
await
|
|
203
|
+
throw new Error('DIRECT_TRANSFER_CANDIDATE_REJECTED');
|
|
204
|
+
await transport.peer.addIceCandidate(signal.candidate);
|
|
156
205
|
return;
|
|
157
206
|
}
|
|
158
207
|
if (signal.kind === 'END_OF_CANDIDATES') {
|
|
159
|
-
await
|
|
208
|
+
await transport.peer.addIceCandidate(null);
|
|
160
209
|
return;
|
|
161
210
|
}
|
|
162
211
|
if (!hostOnlySdp(signal.sdp))
|
|
163
|
-
throw new Error('
|
|
164
|
-
await
|
|
212
|
+
throw new Error('DIRECT_TRANSFER_CANDIDATE_REJECTED');
|
|
213
|
+
await transport.peer.setRemoteDescription({
|
|
165
214
|
type: signal.kind === 'OFFER' ? 'offer' : 'answer',
|
|
166
215
|
sdp: signal.sdp
|
|
167
216
|
});
|
|
168
217
|
if (signal.kind !== 'OFFER')
|
|
169
218
|
return;
|
|
170
|
-
const answer = await
|
|
171
|
-
await
|
|
172
|
-
const localSdp =
|
|
173
|
-
const
|
|
174
|
-
const sdp = filtered.sdp;
|
|
219
|
+
const answer = await transport.peer.createAnswer();
|
|
220
|
+
await transport.peer.setLocalDescription(answer);
|
|
221
|
+
const localSdp = transport.peer.localDescription?.sdp;
|
|
222
|
+
const sdp = filterIceCandidates(localSdp, transport.sentCandidateKeys).sdp;
|
|
175
223
|
const localFingerprint = fingerprint(localSdp);
|
|
176
224
|
if (sdp === undefined || localFingerprint === undefined)
|
|
177
225
|
throw new Error('DIRECT_FINGERPRINT_MISSING');
|
|
178
|
-
this.emit(
|
|
226
|
+
this.emit(transport.command.directSessionId, {
|
|
179
227
|
type: 'direct.signal',
|
|
180
|
-
directSessionId:
|
|
228
|
+
directSessionId: transport.command.directSessionId,
|
|
181
229
|
signal: { kind: 'ANSWER', sdp, fingerprint: localFingerprint }
|
|
182
230
|
});
|
|
183
231
|
}
|
|
184
|
-
|
|
185
|
-
session.channel = channel;
|
|
232
|
+
attachPendingChannel(transport, channel) {
|
|
186
233
|
channel.binaryType = 'arraybuffer';
|
|
234
|
+
let attached = false;
|
|
235
|
+
channel.onmessage = (event) => {
|
|
236
|
+
if (attached)
|
|
237
|
+
return;
|
|
238
|
+
attached = true;
|
|
239
|
+
void this.openChannel(transport, channel, event.data).catch((error) => {
|
|
240
|
+
channel.close();
|
|
241
|
+
const id = openFrameSessionId(event.data);
|
|
242
|
+
if (id !== undefined)
|
|
243
|
+
this.fail(id, errorCode(error), errorMessage(error));
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
async openChannel(transport, channel, data) {
|
|
248
|
+
if (typeof data !== 'string')
|
|
249
|
+
throw new Error('DIRECT_OPEN_REQUIRED');
|
|
250
|
+
const parsed = directDataControlFrameSchema.parse(JSON.parse(data));
|
|
251
|
+
if (parsed.type !== 'OPEN')
|
|
252
|
+
throw new Error('DIRECT_OPEN_REQUIRED');
|
|
253
|
+
const transfer = this.transfers.get(parsed.directSessionId);
|
|
254
|
+
const grant = transfer?.grant;
|
|
255
|
+
if (transfer === undefined ||
|
|
256
|
+
grant === undefined ||
|
|
257
|
+
transfer.command.transportSessionId !== transport.command.directSessionId ||
|
|
258
|
+
parsed.grant !== grant.grant ||
|
|
259
|
+
grant.openExpiresAt <= this.now() ||
|
|
260
|
+
grant.expiresAt <= this.now())
|
|
261
|
+
throw new Error('DIRECT_TRANSFER_GRANT_INVALID');
|
|
262
|
+
transfer.channel = channel;
|
|
263
|
+
transfer.opened = true;
|
|
187
264
|
channel.onmessage = (event) => {
|
|
188
|
-
|
|
189
|
-
.then(() => this.channelMessage(
|
|
190
|
-
.catch((error) => this.fail(
|
|
265
|
+
transfer.processing = transfer.processing
|
|
266
|
+
.then(() => this.channelMessage(transfer, event.data))
|
|
267
|
+
.catch((error) => this.fail(transfer.command.directSessionId, errorCode(error), errorMessage(error)));
|
|
191
268
|
};
|
|
192
269
|
channel.onclose = () => {
|
|
193
|
-
if (this.
|
|
194
|
-
void this.
|
|
270
|
+
if (this.transfers.get(transfer.command.directSessionId) === transfer)
|
|
271
|
+
void this.removeTransfer(transfer, 'DIRECT_CHANNEL_CLOSED');
|
|
195
272
|
};
|
|
273
|
+
await this.open(transfer);
|
|
196
274
|
}
|
|
197
|
-
async channelMessage(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const parsed = directDataControlFrameSchema.parse(JSON.parse(data));
|
|
202
|
-
if (parsed.type !== 'OPEN')
|
|
203
|
-
throw new Error('DIRECT_OPEN_REQUIRED');
|
|
204
|
-
const grant = session.grant;
|
|
205
|
-
if (grant === undefined ||
|
|
206
|
-
parsed.directSessionId !== session.command.directSessionId ||
|
|
207
|
-
parsed.grant !== grant.grant ||
|
|
208
|
-
grant.openExpiresAt <= this.now() ||
|
|
209
|
-
grant.expiresAt <= this.now())
|
|
210
|
-
throw new Error('DIRECT_GRANT_INVALID');
|
|
211
|
-
session.opened = true;
|
|
212
|
-
if (session.command.purpose === 'TRANSFER')
|
|
213
|
-
this.startLeaseWatch(session);
|
|
214
|
-
await this.open(session);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
if (session.command.purpose !== 'TRANSFER' || session.command.transfer === undefined)
|
|
218
|
-
throw new Error('DIRECT_FRAME_INVALID');
|
|
219
|
-
if (session.command.transfer.direction === 'DOWNLOAD') {
|
|
275
|
+
async channelMessage(transfer, data) {
|
|
276
|
+
const descriptor = transfer.command.transfer;
|
|
277
|
+
const expectedSize = transferredSize(descriptor);
|
|
278
|
+
if (descriptor.direction === 'DOWNLOAD') {
|
|
220
279
|
if (typeof data !== 'string')
|
|
221
280
|
throw new Error('DIRECT_FRAME_INVALID');
|
|
222
281
|
const control = directDataControlFrameSchema.parse(JSON.parse(data));
|
|
223
|
-
if (control.type !== 'ACK' || control.offset !==
|
|
282
|
+
if (control.type !== 'ACK' || control.offset !== expectedSize)
|
|
224
283
|
throw new Error('DIRECT_FRAME_INVALID');
|
|
225
|
-
this.emit(
|
|
284
|
+
this.emit(transfer.command.directSessionId, {
|
|
226
285
|
type: 'direct.complete',
|
|
227
|
-
directSessionId:
|
|
228
|
-
size:
|
|
229
|
-
sha256: session.command.transfer.sha256
|
|
286
|
+
directSessionId: transfer.command.directSessionId,
|
|
287
|
+
size: expectedSize
|
|
230
288
|
});
|
|
231
|
-
await this.
|
|
289
|
+
await this.removeTransfer(transfer);
|
|
232
290
|
return;
|
|
233
291
|
}
|
|
234
292
|
if (typeof data === 'string') {
|
|
235
293
|
const control = directDataControlFrameSchema.parse(JSON.parse(data));
|
|
236
|
-
if (control.type !== 'COMPLETE' ||
|
|
294
|
+
if (control.type !== 'COMPLETE' || transfer.upload === undefined)
|
|
237
295
|
throw new Error('DIRECT_FRAME_INVALID');
|
|
238
|
-
if (control.size !==
|
|
239
|
-
control.sha256 !== session.command.transfer.sha256)
|
|
296
|
+
if (control.size !== descriptor.size)
|
|
240
297
|
throw new Error('DIRECT_INTEGRITY_FAILED');
|
|
241
|
-
await
|
|
242
|
-
|
|
243
|
-
this.emit(
|
|
298
|
+
await transfer.upload.complete(control.size);
|
|
299
|
+
transfer.uploadCommitted = true;
|
|
300
|
+
this.emit(transfer.command.directSessionId, {
|
|
244
301
|
type: 'direct.complete',
|
|
245
|
-
directSessionId:
|
|
246
|
-
size: control.size
|
|
247
|
-
sha256: control.sha256
|
|
302
|
+
directSessionId: transfer.command.directSessionId,
|
|
303
|
+
size: control.size
|
|
248
304
|
});
|
|
249
|
-
await this.
|
|
305
|
+
await this.removeTransfer(transfer);
|
|
250
306
|
return;
|
|
251
307
|
}
|
|
252
308
|
const bytes = toBytes(data);
|
|
253
|
-
if (bytes.byteLength === 0 || bytes.byteLength > 256 * 1024 ||
|
|
309
|
+
if (bytes.byteLength === 0 || bytes.byteLength > 256 * 1024 || transfer.upload === undefined)
|
|
254
310
|
throw new Error('DIRECT_CHUNK_INVALID');
|
|
255
|
-
const offset = await
|
|
256
|
-
|
|
311
|
+
const offset = await transfer.upload.write(bytes);
|
|
312
|
+
transfer.channel?.send(JSON.stringify({ type: 'ACK', offset }));
|
|
257
313
|
}
|
|
258
|
-
async open(
|
|
259
|
-
|
|
260
|
-
session.channel?.send(JSON.stringify({ type: 'ACK', offset: 0 }));
|
|
261
|
-
this.emit(session.command.directSessionId, {
|
|
262
|
-
type: 'direct.probe-result',
|
|
263
|
-
directSessionId: session.command.directSessionId,
|
|
264
|
-
result: 'DIRECT_AVAILABLE'
|
|
265
|
-
});
|
|
266
|
-
await this.remove(session);
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
const descriptor = session.command.transfer;
|
|
270
|
-
if (descriptor === undefined)
|
|
271
|
-
throw new Error('DIRECT_TRANSFER_INVALID');
|
|
314
|
+
async open(transfer) {
|
|
315
|
+
const descriptor = transfer.command.transfer;
|
|
272
316
|
if (descriptor.direction === 'UPLOAD') {
|
|
273
317
|
if (this.options.openUpload === undefined)
|
|
274
318
|
throw new Error('DIRECT_UPLOAD_UNAVAILABLE');
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
this.emit(
|
|
319
|
+
transfer.upload = await this.options.openUpload(descriptor);
|
|
320
|
+
transfer.channel?.send(JSON.stringify({ type: 'ACK', offset: transfer.upload.offset }));
|
|
321
|
+
this.emit(transfer.command.directSessionId, {
|
|
278
322
|
type: 'direct.opened',
|
|
279
|
-
directSessionId:
|
|
280
|
-
offset:
|
|
323
|
+
directSessionId: transfer.command.directSessionId,
|
|
324
|
+
offset: transfer.upload.offset
|
|
281
325
|
});
|
|
282
326
|
return;
|
|
283
327
|
}
|
|
284
328
|
if (this.options.openDownload === undefined)
|
|
285
329
|
throw new Error('DIRECT_DOWNLOAD_UNAVAILABLE');
|
|
286
330
|
const source = await this.options.openDownload(descriptor);
|
|
287
|
-
|
|
288
|
-
|
|
331
|
+
const expectedSize = transferredSize(descriptor);
|
|
332
|
+
transfer.channel?.send(JSON.stringify({ type: 'ACK', offset: source.offset }));
|
|
333
|
+
this.emit(transfer.command.directSessionId, {
|
|
289
334
|
type: 'direct.opened',
|
|
290
|
-
directSessionId:
|
|
335
|
+
directSessionId: transfer.command.directSessionId,
|
|
291
336
|
offset: source.offset
|
|
292
337
|
});
|
|
293
|
-
for await (const chunk of source.chunks)
|
|
338
|
+
for await (const chunk of source.chunks)
|
|
294
339
|
for (let offset = 0; offset < chunk.byteLength; offset += 256 * 1024) {
|
|
295
|
-
if (this.
|
|
340
|
+
if (this.transfers.get(transfer.command.directSessionId) !== transfer)
|
|
296
341
|
return;
|
|
297
|
-
const channel =
|
|
342
|
+
const channel = transfer.channel;
|
|
298
343
|
if (channel === undefined)
|
|
299
344
|
throw new Error('DIRECT_CHANNEL_CLOSED');
|
|
300
|
-
await this.waitForSendCapacity(
|
|
345
|
+
await this.waitForSendCapacity(transfer, channel);
|
|
301
346
|
const end = Math.min(chunk.byteLength, offset + 256 * 1024);
|
|
302
347
|
channel.send(chunk.buffer.slice(chunk.byteOffset + offset, chunk.byteOffset + end));
|
|
303
348
|
}
|
|
304
|
-
}
|
|
305
|
-
session.channel?.send(JSON.stringify({
|
|
306
|
-
type: 'COMPLETE',
|
|
307
|
-
size: descriptor.size,
|
|
308
|
-
sha256: descriptor.sha256
|
|
309
|
-
}));
|
|
349
|
+
transfer.channel?.send(JSON.stringify({ type: 'COMPLETE', size: expectedSize }));
|
|
310
350
|
}
|
|
311
|
-
async waitForSendCapacity(
|
|
351
|
+
async waitForSendCapacity(transfer, channel) {
|
|
312
352
|
if (channel.readyState !== 'open')
|
|
313
353
|
throw new Error('DIRECT_CHANNEL_CLOSED');
|
|
314
354
|
if (channel.bufferedAmount <= DATA_CHANNEL_HIGH_WATER_BYTES)
|
|
@@ -318,62 +358,88 @@ export class DirectTransferService {
|
|
|
318
358
|
const complete = (error) => {
|
|
319
359
|
channel.removeEventListener('bufferedamountlow', available);
|
|
320
360
|
channel.removeEventListener('close', closed);
|
|
321
|
-
|
|
322
|
-
resolve();
|
|
323
|
-
else
|
|
324
|
-
reject(error);
|
|
361
|
+
error === undefined ? resolve() : reject(error);
|
|
325
362
|
};
|
|
326
363
|
const available = () => complete();
|
|
327
364
|
const closed = () => complete(new Error('DIRECT_CHANNEL_CLOSED'));
|
|
328
365
|
channel.addEventListener('bufferedamountlow', available, { once: true });
|
|
329
366
|
channel.addEventListener('close', closed, { once: true });
|
|
330
|
-
if (this.
|
|
367
|
+
if (this.transfers.get(transfer.command.directSessionId) !== transfer ||
|
|
331
368
|
channel.readyState !== 'open')
|
|
332
369
|
closed();
|
|
333
370
|
else if (channel.bufferedAmount <= DATA_CHANNEL_LOW_WATER_BYTES)
|
|
334
371
|
available();
|
|
335
372
|
});
|
|
336
373
|
}
|
|
337
|
-
startLeaseWatch(
|
|
338
|
-
|
|
339
|
-
session.leaseTimer = setInterval(() => {
|
|
374
|
+
startLeaseWatch(expiresAt, lastAlive, expire) {
|
|
375
|
+
const timer = setInterval(() => {
|
|
340
376
|
const now = this.now();
|
|
341
|
-
if (
|
|
342
|
-
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
if (now - (session.lastLeaseAliveAt ?? now) > this.leaseGraceMs)
|
|
346
|
-
void this.remove(session, 'DIRECT_LEASE_EXPIRED');
|
|
377
|
+
if (expiresAt() <= now || now - lastAlive() > this.leaseGraceMs)
|
|
378
|
+
expire();
|
|
347
379
|
}, this.leaseCheckMs);
|
|
380
|
+
return timer;
|
|
348
381
|
}
|
|
349
382
|
emit(directSessionId, event) {
|
|
350
|
-
if (this.
|
|
383
|
+
if (this.transports.has(directSessionId) || this.transfers.has(directSessionId))
|
|
351
384
|
this.options.send(event.type, event);
|
|
352
385
|
}
|
|
353
386
|
fail(directSessionId, code, message) {
|
|
354
|
-
const session = this.sessions.get(directSessionId);
|
|
355
387
|
this.options.send('direct.error', { type: 'direct.error', directSessionId, code, message });
|
|
356
|
-
|
|
357
|
-
|
|
388
|
+
const transfer = this.transfers.get(directSessionId);
|
|
389
|
+
if (transfer !== undefined)
|
|
390
|
+
void this.removeTransfer(transfer);
|
|
391
|
+
const transport = this.transports.get(directSessionId);
|
|
392
|
+
if (transport !== undefined)
|
|
393
|
+
this.removeTransport(transport);
|
|
394
|
+
}
|
|
395
|
+
removeTransport(transport, code) {
|
|
396
|
+
if (!this.transports.delete(transport.command.directSessionId))
|
|
397
|
+
return;
|
|
398
|
+
clearInterval(transport.leaseTimer);
|
|
399
|
+
for (const transfer of [...this.transfers.values()])
|
|
400
|
+
if (transfer.command.transportSessionId === transport.command.directSessionId)
|
|
401
|
+
void this.removeTransfer(transfer, code ?? 'DIRECT_CONNECTION_FAILED');
|
|
402
|
+
transport.peer.close();
|
|
403
|
+
if (code !== undefined)
|
|
404
|
+
this.options.send('direct.error', {
|
|
405
|
+
type: 'direct.error',
|
|
406
|
+
directSessionId: transport.command.directSessionId,
|
|
407
|
+
code,
|
|
408
|
+
message: directErrorMessage(code)
|
|
409
|
+
});
|
|
358
410
|
}
|
|
359
|
-
async
|
|
360
|
-
if (!this.
|
|
411
|
+
async removeTransfer(transfer, code) {
|
|
412
|
+
if (!this.transfers.delete(transfer.command.directSessionId))
|
|
361
413
|
return;
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
session.channel?.close();
|
|
367
|
-
session.peer.close();
|
|
414
|
+
clearInterval(transfer.leaseTimer);
|
|
415
|
+
if (!transfer.uploadCommitted)
|
|
416
|
+
await transfer.upload?.cancel().catch(() => undefined);
|
|
417
|
+
transfer.channel?.close();
|
|
368
418
|
if (code !== undefined)
|
|
369
419
|
this.options.send('direct.error', {
|
|
370
420
|
type: 'direct.error',
|
|
371
|
-
directSessionId:
|
|
421
|
+
directSessionId: transfer.command.directSessionId,
|
|
372
422
|
code,
|
|
373
423
|
message: directErrorMessage(code)
|
|
374
424
|
});
|
|
375
425
|
}
|
|
376
426
|
}
|
|
427
|
+
function transferredSize(descriptor) {
|
|
428
|
+
return descriptor.direction === 'DOWNLOAD' && descriptor.range !== undefined
|
|
429
|
+
? descriptor.range.length
|
|
430
|
+
: descriptor.size;
|
|
431
|
+
}
|
|
432
|
+
function openFrameSessionId(data) {
|
|
433
|
+
if (typeof data !== 'string')
|
|
434
|
+
return undefined;
|
|
435
|
+
try {
|
|
436
|
+
const value = JSON.parse(data);
|
|
437
|
+
return typeof value.directSessionId === 'string' ? value.directSessionId : undefined;
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
377
443
|
function bindIceUdpMuxListener(range, create) {
|
|
378
444
|
let lastError;
|
|
379
445
|
for (let port = range.begin; port <= range.end; port += 1) {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { tmpdir } from 'node:os';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
2
|
import { isAbsolute, win32 } from 'node:path';
|
|
4
3
|
import { listWorkspaceFiles, mutateWorkspaceFile, normalizeFilePreviewPath, readAllowedFileContentRange, readWorkspaceFileContentRange, readAllowedTextFile, readWorkspaceTextFile, writeWorkspaceTextFile, WorkspaceFileIndex } from '../workspace.js';
|
|
5
4
|
const INDEX_CACHE_LIMIT = 8;
|
|
@@ -62,50 +61,65 @@ export class WorkspaceFileService {
|
|
|
62
61
|
readDirect(workspace, input, workspacePath = workspace.path) {
|
|
63
62
|
const service = this;
|
|
64
63
|
return (async function* () {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
const initial = (await service.readContent(workspace, { path: input.path, offset: input.range?.offset ?? 0, limit: 1 }, workspacePath));
|
|
65
|
+
if (initial.size !== input.size || initial.sourceRevision !== input.revision)
|
|
66
|
+
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
67
|
+
let offset = input.range?.offset ?? 0;
|
|
68
|
+
const endOffset = input.range === undefined ? input.size : input.range.offset + input.range.length;
|
|
69
|
+
while (offset < endOffset) {
|
|
68
70
|
const range = (await service.readContent(workspace, {
|
|
69
71
|
path: input.path,
|
|
70
72
|
offset,
|
|
71
|
-
limit: 512 * 1024
|
|
73
|
+
limit: Math.min(512 * 1024, endOffset - offset)
|
|
72
74
|
}, workspacePath));
|
|
73
|
-
if (range.size !== input.size)
|
|
75
|
+
if (range.size !== input.size || range.sourceRevision !== input.revision)
|
|
74
76
|
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
75
77
|
const bytes = Buffer.from(range.contentBase64, 'base64');
|
|
76
|
-
hash.update(bytes);
|
|
77
78
|
offset += bytes.length;
|
|
78
79
|
if (bytes.length > 0)
|
|
79
80
|
yield bytes;
|
|
80
|
-
if (
|
|
81
|
+
if (offset === endOffset)
|
|
81
82
|
break;
|
|
82
83
|
if (range.nextOffset !== offset)
|
|
83
84
|
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
84
85
|
}
|
|
85
|
-
if (offset !==
|
|
86
|
+
if (offset !== endOffset)
|
|
86
87
|
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
87
88
|
})();
|
|
88
89
|
}
|
|
89
90
|
async directMetadata(workspace, input, workspacePath = workspace.path) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
91
|
+
if (input.offset !== undefined ||
|
|
92
|
+
input.length !== undefined ||
|
|
93
|
+
input.size !== undefined ||
|
|
94
|
+
input.revision !== undefined) {
|
|
95
|
+
if (!Number.isSafeInteger(input.offset) ||
|
|
96
|
+
input.offset < 0 ||
|
|
97
|
+
!Number.isSafeInteger(input.length) ||
|
|
98
|
+
input.length < 1 ||
|
|
99
|
+
!Number.isSafeInteger(input.size) ||
|
|
100
|
+
input.size < 0 ||
|
|
101
|
+
typeof input.revision !== 'string' ||
|
|
102
|
+
!/^[a-f0-9]{64}$/u.test(input.revision))
|
|
103
|
+
throw new Error('FILE_RANGE_INVALID');
|
|
104
|
+
const offset = input.offset;
|
|
105
|
+
const length = input.length;
|
|
106
|
+
const expectedSize = input.size;
|
|
107
|
+
if (offset + length > expectedSize)
|
|
108
|
+
throw new Error('FILE_RANGE_INVALID');
|
|
109
|
+
const read = (await this.readContent(workspace, { ...input, offset, limit: length }, workspacePath));
|
|
110
|
+
const bytes = Buffer.from(read.contentBase64, 'base64');
|
|
111
|
+
if (read.size !== expectedSize ||
|
|
112
|
+
read.sourceRevision !== input.revision ||
|
|
113
|
+
bytes.length !== length)
|
|
104
114
|
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
115
|
+
return {
|
|
116
|
+
size: expectedSize,
|
|
117
|
+
revision: input.revision,
|
|
118
|
+
range: { offset, length }
|
|
119
|
+
};
|
|
105
120
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return { size, sha256: hash.digest('hex') };
|
|
121
|
+
const metadata = (await this.readContent(workspace, { ...input, offset: 0, limit: 1 }, workspacePath));
|
|
122
|
+
return { size: metadata.size, revision: metadata.sourceRevision };
|
|
109
123
|
}
|
|
110
124
|
async write(workspace, input) {
|
|
111
125
|
if (typeof input.path !== 'string' ||
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createReadStream } from 'node:fs';
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
3
2
|
import { lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
|
|
4
3
|
import { dirname, join, posix, resolve } from 'node:path';
|
|
5
4
|
import { parseComposerAttachmentUpload, validComposerAttachmentName } from '../util/node-operation-parsers.js';
|
|
@@ -10,7 +9,6 @@ const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
|
|
10
9
|
export class WorkspaceUploadService {
|
|
11
10
|
state;
|
|
12
11
|
onCompleted;
|
|
13
|
-
directHashes = new Map();
|
|
14
12
|
constructor(state, onCompleted) {
|
|
15
13
|
this.state = state;
|
|
16
14
|
this.onCompleted = onCompleted;
|
|
@@ -114,7 +112,6 @@ export class WorkspaceUploadService {
|
|
|
114
112
|
mime: input.composerAttachment.mime
|
|
115
113
|
})
|
|
116
114
|
});
|
|
117
|
-
this.directHashes.set(status.uploadId, input.sha256);
|
|
118
115
|
return status;
|
|
119
116
|
}
|
|
120
117
|
async writeDirect(uploadId, bytes) {
|
|
@@ -125,19 +122,11 @@ export class WorkspaceUploadService {
|
|
|
125
122
|
});
|
|
126
123
|
return status.receivedBytes;
|
|
127
124
|
}
|
|
128
|
-
async completeDirect(uploadId, size
|
|
125
|
+
async completeDirect(uploadId, size) {
|
|
129
126
|
const upload = this.require(uploadId);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
throw new Error('UPLOAD_HASH_INVALID');
|
|
133
|
-
await upload.handle.sync();
|
|
134
|
-
const actual = await fileSha256(upload.temporaryPath);
|
|
135
|
-
if (actual !== expected) {
|
|
136
|
-
await this.cancel(uploadId);
|
|
137
|
-
throw new Error('UPLOAD_HASH_INVALID');
|
|
138
|
-
}
|
|
127
|
+
if (size !== upload.size)
|
|
128
|
+
throw new Error('UPLOAD_SIZE_INVALID');
|
|
139
129
|
await this.complete(uploadId);
|
|
140
|
-
this.directHashes.delete(uploadId);
|
|
141
130
|
}
|
|
142
131
|
status(uploadId) {
|
|
143
132
|
const upload = this.require(uploadId);
|
|
@@ -203,10 +192,8 @@ export class WorkspaceUploadService {
|
|
|
203
192
|
if (typeof uploadId !== 'string')
|
|
204
193
|
throw new Error('UPLOAD_NOT_FOUND');
|
|
205
194
|
const active = this.state.uploads.get(uploadId);
|
|
206
|
-
if (active !== undefined)
|
|
207
|
-
this.directHashes.delete(active.id);
|
|
195
|
+
if (active !== undefined)
|
|
208
196
|
return this.remove(active);
|
|
209
|
-
}
|
|
210
197
|
const attachment = this.state.composerAttachments.get(uploadId);
|
|
211
198
|
if (attachment === undefined)
|
|
212
199
|
throw new Error('UPLOAD_NOT_FOUND');
|
|
@@ -246,7 +233,6 @@ export class WorkspaceUploadService {
|
|
|
246
233
|
for (const attachment of this.state.composerAttachments.values())
|
|
247
234
|
await this.removeCompleted(attachment);
|
|
248
235
|
this.state.composerAttachments.clear();
|
|
249
|
-
this.directHashes.clear();
|
|
250
236
|
}
|
|
251
237
|
require(id) {
|
|
252
238
|
if (typeof id !== 'string')
|
|
@@ -258,7 +244,6 @@ export class WorkspaceUploadService {
|
|
|
258
244
|
}
|
|
259
245
|
async remove(upload) {
|
|
260
246
|
this.state.uploads.delete(upload.id);
|
|
261
|
-
this.directHashes.delete(upload.id);
|
|
262
247
|
clearTimeout(upload.timer);
|
|
263
248
|
await upload.handle.close().catch(() => undefined);
|
|
264
249
|
await rm(upload.temporaryPath, { force: true }).catch(() => undefined);
|
|
@@ -296,12 +281,6 @@ export class WorkspaceUploadService {
|
|
|
296
281
|
};
|
|
297
282
|
}
|
|
298
283
|
}
|
|
299
|
-
async function fileSha256(path) {
|
|
300
|
-
const hash = createHash('sha256');
|
|
301
|
-
for await (const chunk of createReadStream(path))
|
|
302
|
-
hash.update(chunk);
|
|
303
|
-
return hash.digest('hex');
|
|
304
|
-
}
|
|
305
284
|
async function safeDirectory(path) {
|
|
306
285
|
const value = await lstat(path);
|
|
307
286
|
if (!value.isDirectory() || value.isSymbolicLink())
|
|
@@ -235,6 +235,8 @@ export class WorkspaceWorkbenchService {
|
|
|
235
235
|
};
|
|
236
236
|
}
|
|
237
237
|
async directDownload(descriptor) {
|
|
238
|
+
if (descriptor.direction !== 'DOWNLOAD')
|
|
239
|
+
throw new Error('DIRECT_RESOURCE_UNSUPPORTED');
|
|
238
240
|
const workspace = this.options.requireWorkspace(descriptor.resource.workspaceId);
|
|
239
241
|
if (descriptor.resource.kind === 'WORKSPACE_FILE') {
|
|
240
242
|
const input = {
|
|
@@ -245,41 +247,47 @@ export class WorkspaceWorkbenchService {
|
|
|
245
247
|
: { worktreePath: descriptor.resource.worktreePath })
|
|
246
248
|
};
|
|
247
249
|
return {
|
|
248
|
-
offset: descriptor.
|
|
250
|
+
offset: descriptor.range?.offset ?? 0,
|
|
249
251
|
chunks: this.options.files.readDirect(workspace, {
|
|
250
252
|
path: descriptor.resource.path,
|
|
251
253
|
size: descriptor.size,
|
|
252
|
-
|
|
254
|
+
revision: descriptor.revision,
|
|
255
|
+
...(descriptor.range === undefined ? {} : { range: descriptor.range })
|
|
253
256
|
}, await this.fileRepository(workspace, input))
|
|
254
257
|
};
|
|
255
258
|
}
|
|
259
|
+
const resource = descriptor.resource;
|
|
256
260
|
const input = {
|
|
257
261
|
workspaceId: workspace.id,
|
|
258
|
-
path:
|
|
259
|
-
commit:
|
|
260
|
-
...(
|
|
261
|
-
|
|
262
|
-
: { repositoryPath: descriptor.resource.repositoryPath }),
|
|
263
|
-
...(descriptor.resource.worktreePath === undefined
|
|
264
|
-
? {}
|
|
265
|
-
: { worktreePath: descriptor.resource.worktreePath })
|
|
262
|
+
path: resource.path,
|
|
263
|
+
commit: resource.commit,
|
|
264
|
+
...(resource.repositoryPath === undefined ? {} : { repositoryPath: resource.repositoryPath }),
|
|
265
|
+
...(resource.worktreePath === undefined ? {} : { worktreePath: resource.worktreePath })
|
|
266
266
|
};
|
|
267
267
|
const repository = await this.gitRepository(input);
|
|
268
|
-
|
|
269
|
-
if (content.size !== descriptor.size)
|
|
268
|
+
if (descriptor.revision !== gitResourceRevision(resource.commit, resource.path))
|
|
270
269
|
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
270
|
+
const refs = this.refs(input);
|
|
271
271
|
return {
|
|
272
|
-
offset: descriptor.
|
|
272
|
+
offset: descriptor.range?.offset ?? 0,
|
|
273
273
|
chunks: (async function* () {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
274
|
+
let offset = descriptor.range?.offset ?? 0;
|
|
275
|
+
const rangeStart = descriptor.range?.offset ?? 0;
|
|
276
|
+
const rangeEnd = rangeStart + (descriptor.range?.length ?? descriptor.size);
|
|
277
|
+
while (offset < rangeEnd) {
|
|
278
|
+
const range = await readGitHistoryFileContentRange(repository, resource.commit, resource.path, offset, Math.min(512 * 1024, rangeEnd - offset), refs);
|
|
279
|
+
if (range.size !== descriptor.size)
|
|
280
|
+
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
281
|
+
const bytes = Buffer.from(range.contentBase64, 'base64');
|
|
278
282
|
offset += bytes.length;
|
|
279
283
|
if (bytes.length > 0)
|
|
280
284
|
yield bytes;
|
|
285
|
+
if (offset === rangeEnd)
|
|
286
|
+
break;
|
|
287
|
+
if (range.nextOffset !== offset)
|
|
288
|
+
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
281
289
|
}
|
|
282
|
-
if (offset !==
|
|
290
|
+
if (offset !== rangeEnd)
|
|
283
291
|
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
284
292
|
})()
|
|
285
293
|
};
|
|
@@ -288,16 +296,37 @@ export class WorkspaceWorkbenchService {
|
|
|
288
296
|
const repository = await this.gitRepository(input);
|
|
289
297
|
if (typeof input.commit !== 'string' || typeof input.path !== 'string')
|
|
290
298
|
throw new Error('GIT_HISTORY_COMMIT_INVALID');
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
offset
|
|
299
|
+
const revision = gitResourceRevision(input.commit, input.path);
|
|
300
|
+
if (input.offset !== undefined ||
|
|
301
|
+
input.length !== undefined ||
|
|
302
|
+
input.size !== undefined ||
|
|
303
|
+
input.revision !== undefined) {
|
|
304
|
+
if (!Number.isSafeInteger(input.offset) ||
|
|
305
|
+
input.offset < 0 ||
|
|
306
|
+
!Number.isSafeInteger(input.length) ||
|
|
307
|
+
input.length < 1 ||
|
|
308
|
+
input.length > 512 * 1024 ||
|
|
309
|
+
!Number.isSafeInteger(input.size) ||
|
|
310
|
+
input.size < 0 ||
|
|
311
|
+
input.revision !== revision)
|
|
312
|
+
throw new Error('FILE_RANGE_INVALID');
|
|
313
|
+
const offset = input.offset;
|
|
314
|
+
const length = input.length;
|
|
315
|
+
const size = input.size;
|
|
316
|
+
if (offset + length > size)
|
|
317
|
+
throw new Error('FILE_RANGE_INVALID');
|
|
318
|
+
const range = await readGitHistoryFileContentRange(repository, input.commit, input.path, offset, length, this.refs(input));
|
|
319
|
+
const bytes = Buffer.from(range.contentBase64, 'base64');
|
|
320
|
+
if (range.size !== size || bytes.length !== length)
|
|
321
|
+
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
322
|
+
return {
|
|
323
|
+
size,
|
|
324
|
+
revision,
|
|
325
|
+
range: { offset, length }
|
|
326
|
+
};
|
|
297
327
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
return { size: content.size, sha256: hash.digest('hex') };
|
|
328
|
+
const content = await openGitHistoryFileContent(repository, input.commit, input.path, this.refs(input));
|
|
329
|
+
return { size: content.size, revision };
|
|
301
330
|
}
|
|
302
331
|
async diff(input) {
|
|
303
332
|
if (typeof input.workspaceId !== 'string' ||
|
|
@@ -314,6 +343,9 @@ export class WorkspaceWorkbenchService {
|
|
|
314
343
|
return workspace;
|
|
315
344
|
}
|
|
316
345
|
}
|
|
346
|
+
function gitResourceRevision(commit, path) {
|
|
347
|
+
return createHash('sha256').update(commit).update('\0').update(path).digest('hex');
|
|
348
|
+
}
|
|
317
349
|
function record(value) {
|
|
318
350
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
319
351
|
? value
|
package/dist/workspace.js
CHANGED
|
@@ -672,6 +672,7 @@ export async function readWorkspaceFileContentRange(workspaceRoot, requestedPath
|
|
|
672
672
|
return {
|
|
673
673
|
path: relative(root, file).split(sep).join('/'),
|
|
674
674
|
size: metadata.size,
|
|
675
|
+
sourceRevision: fileSourceRevision(metadata),
|
|
675
676
|
offset,
|
|
676
677
|
contentBase64: source.subarray(0, bytesRead).toString('base64'),
|
|
677
678
|
nextOffset: end < metadata.size ? end : null
|
|
@@ -701,6 +702,7 @@ export async function readAllowedFileContentRange(requestedPath, allowedRoots, o
|
|
|
701
702
|
return {
|
|
702
703
|
path: file,
|
|
703
704
|
size: metadata.size,
|
|
705
|
+
sourceRevision: fileSourceRevision(metadata),
|
|
704
706
|
offset,
|
|
705
707
|
contentBase64: source.subarray(0, bytesRead).toString('base64'),
|
|
706
708
|
nextOffset: end < metadata.size ? end : null
|
|
@@ -710,6 +712,11 @@ export async function readAllowedFileContentRange(requestedPath, allowedRoots, o
|
|
|
710
712
|
await handle.close();
|
|
711
713
|
}
|
|
712
714
|
}
|
|
715
|
+
function fileSourceRevision(metadata) {
|
|
716
|
+
return createHash('sha256')
|
|
717
|
+
.update([metadata.dev, metadata.ino, metadata.size, metadata.mtimeMs, metadata.ctimeMs].join(':'))
|
|
718
|
+
.digest('hex');
|
|
719
|
+
}
|
|
713
720
|
/** Reads one absolute file only when the resolved target remains within a preview root. */
|
|
714
721
|
export async function readAllowedTextFile(requestedPath, allowedRoots, offset = 0, limit = FILE_READ_LIMIT) {
|
|
715
722
|
const { file, metadata } = await resolveAllowedFile(requestedPath, allowedRoots);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myagentroam/node",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.71",
|
|
4
4
|
"description": "MyAgentRoam Node runtime CLI.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"node-pty": "1.1.0",
|
|
29
29
|
"ws": "^8.21.3",
|
|
30
30
|
"zod": "4.4.3",
|
|
31
|
-
"@myagentroam/
|
|
32
|
-
"@myagentroam/
|
|
31
|
+
"@myagentroam/protocol": "0.9.71",
|
|
32
|
+
"@myagentroam/agent": "0.9.71"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/ws": "^8.18.1"
|