@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.
@@ -11,10 +11,11 @@ const runtimeCredentialCapability = () => ({
11
11
  httpVersions: ['1.1', '2']
12
12
  });
13
13
  const workspaceDirectTransferCapability = () => ({
14
- apiVersion: 1,
15
- probe: true,
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, sha256) => this.workspaceUploadService.completeDirect(upload.uploadId, size, sha256),
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 MAX_ACTIVE_SESSIONS = 32;
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
- sessions = new Map();
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 session of [...this.sessions.values()])
57
- void this.remove(session, 'DIRECT_NODE_STOPPED');
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
- this.prepare(command);
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
- await this.signal(session, command.signal);
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
- if (command.browserFingerprint !== fingerprint(session.peer.remoteDescription?.sdp) ||
80
- command.nodeFingerprint !== fingerprint(session.peer.localDescription?.sdp) ||
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 > session.command.expiresAt)
83
- throw new Error('DIRECT_GRANT_INVALID');
84
- session.grant = command;
94
+ command.expiresAt > transfer.command.expiresAt)
95
+ throw new Error('DIRECT_TRANSFER_GRANT_INVALID');
96
+ transfer.grant = command;
85
97
  return;
86
98
  }
87
- if (command.type === 'direct.heartbeat') {
88
- if (session.command.expiresAt <= this.now())
89
- await this.remove(session, 'DIRECT_SESSION_EXPIRED');
90
- else
91
- session.lastLeaseAliveAt = this.now();
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
- await this.remove(session, command.code);
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
- prepare(command) {
119
+ prepareTransport(command) {
97
120
  if (command.nodeGeneration !== this.options.nodeGeneration())
98
121
  throw new Error('DIRECT_NODE_GENERATION_MISMATCH');
99
- if (this.sessions.has(command.directSessionId))
100
- throw new Error('DIRECT_SESSION_CONFLICT');
101
- if (this.sessions.size >= MAX_ACTIVE_SESSIONS)
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 session = {
127
+ const transport = {
105
128
  command,
106
129
  peer,
107
- opened: false,
108
- uploadCommitted: false,
109
- processing: Promise.resolve(),
110
- sentCandidateKeys: new Set()
130
+ sentCandidateKeys: new Set(),
131
+ lastLeaseAliveAt: this.now(),
132
+ leaseExpiresAt: command.expiresAt,
133
+ leaseTimer: undefined
111
134
  };
112
- this.sessions.set(command.directSessionId, session);
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, session.sentCandidateKeys))
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.attachChannel(session, event.channel);
164
+ peer.ondatachannel = (event) => this.attachPendingChannel(transport, event.channel);
141
165
  peer.onconnectionstatechange = () => {
142
166
  if (peer.connectionState === 'connected')
143
- this.emit(session.command.directSessionId, {
167
+ this.emit(command.directSessionId, {
144
168
  type: 'direct.connected',
145
- directSessionId: session.command.directSessionId
169
+ directSessionId: command.directSessionId
146
170
  });
147
171
  if (peer.connectionState === 'failed' || peer.connectionState === 'closed')
148
- void this.remove(session, 'DIRECT_CONNECTION_FAILED');
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(session, signal) {
200
+ async signal(transport, signal) {
152
201
  if (signal.kind === 'CANDIDATE') {
153
202
  if (!safeHostCandidate(signal.candidate.candidate))
154
- throw new Error('DIRECT_CANDIDATE_REJECTED');
155
- await session.peer.addIceCandidate(signal.candidate);
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 session.peer.addIceCandidate(null);
208
+ await transport.peer.addIceCandidate(null);
160
209
  return;
161
210
  }
162
211
  if (!hostOnlySdp(signal.sdp))
163
- throw new Error('DIRECT_CANDIDATE_REJECTED');
164
- await session.peer.setRemoteDescription({
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 session.peer.createAnswer();
171
- await session.peer.setLocalDescription(answer);
172
- const localSdp = session.peer.localDescription?.sdp;
173
- const filtered = filterIceCandidates(localSdp, session.sentCandidateKeys);
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(session.command.directSessionId, {
226
+ this.emit(transport.command.directSessionId, {
179
227
  type: 'direct.signal',
180
- directSessionId: session.command.directSessionId,
228
+ directSessionId: transport.command.directSessionId,
181
229
  signal: { kind: 'ANSWER', sdp, fingerprint: localFingerprint }
182
230
  });
183
231
  }
184
- attachChannel(session, channel) {
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
- session.processing = session.processing
189
- .then(() => this.channelMessage(session, event.data))
190
- .catch((error) => this.fail(session.command.directSessionId, errorCode(error), errorMessage(error)));
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.sessions.get(session.command.directSessionId) === session)
194
- void this.remove(session, 'DIRECT_CHANNEL_CLOSED');
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(session, data) {
198
- if (!session.opened) {
199
- if (typeof data !== 'string')
200
- throw new Error('DIRECT_OPEN_REQUIRED');
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 !== session.command.transfer.size)
282
+ if (control.type !== 'ACK' || control.offset !== expectedSize)
224
283
  throw new Error('DIRECT_FRAME_INVALID');
225
- this.emit(session.command.directSessionId, {
284
+ this.emit(transfer.command.directSessionId, {
226
285
  type: 'direct.complete',
227
- directSessionId: session.command.directSessionId,
228
- size: session.command.transfer.size,
229
- sha256: session.command.transfer.sha256
286
+ directSessionId: transfer.command.directSessionId,
287
+ size: expectedSize
230
288
  });
231
- await this.remove(session);
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' || session.upload === undefined)
294
+ if (control.type !== 'COMPLETE' || transfer.upload === undefined)
237
295
  throw new Error('DIRECT_FRAME_INVALID');
238
- if (control.size !== session.command.transfer.size ||
239
- control.sha256 !== session.command.transfer.sha256)
296
+ if (control.size !== descriptor.size)
240
297
  throw new Error('DIRECT_INTEGRITY_FAILED');
241
- await session.upload.complete(control.size, control.sha256);
242
- session.uploadCommitted = true;
243
- this.emit(session.command.directSessionId, {
298
+ await transfer.upload.complete(control.size);
299
+ transfer.uploadCommitted = true;
300
+ this.emit(transfer.command.directSessionId, {
244
301
  type: 'direct.complete',
245
- directSessionId: session.command.directSessionId,
246
- size: control.size,
247
- sha256: control.sha256
302
+ directSessionId: transfer.command.directSessionId,
303
+ size: control.size
248
304
  });
249
- await this.remove(session);
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 || session.upload === undefined)
309
+ if (bytes.byteLength === 0 || bytes.byteLength > 256 * 1024 || transfer.upload === undefined)
254
310
  throw new Error('DIRECT_CHUNK_INVALID');
255
- const offset = await session.upload.write(bytes);
256
- session.channel?.send(JSON.stringify({ type: 'ACK', offset }));
311
+ const offset = await transfer.upload.write(bytes);
312
+ transfer.channel?.send(JSON.stringify({ type: 'ACK', offset }));
257
313
  }
258
- async open(session) {
259
- if (session.command.purpose === 'PROBE') {
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
- session.upload = await this.options.openUpload(descriptor);
276
- session.channel?.send(JSON.stringify({ type: 'ACK', offset: session.upload.offset }));
277
- this.emit(session.command.directSessionId, {
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: session.command.directSessionId,
280
- offset: session.upload.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
- session.channel?.send(JSON.stringify({ type: 'ACK', offset: source.offset }));
288
- this.emit(session.command.directSessionId, {
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: session.command.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.sessions.get(session.command.directSessionId) !== session)
340
+ if (this.transfers.get(transfer.command.directSessionId) !== transfer)
296
341
  return;
297
- const channel = session.channel;
342
+ const channel = transfer.channel;
298
343
  if (channel === undefined)
299
344
  throw new Error('DIRECT_CHANNEL_CLOSED');
300
- await this.waitForSendCapacity(session, channel);
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(session, channel) {
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
- if (error === undefined)
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.sessions.get(session.command.directSessionId) !== session ||
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(session) {
338
- session.lastLeaseAliveAt = this.now();
339
- session.leaseTimer = setInterval(() => {
374
+ startLeaseWatch(expiresAt, lastAlive, expire) {
375
+ const timer = setInterval(() => {
340
376
  const now = this.now();
341
- if (session.command.expiresAt <= now) {
342
- void this.remove(session, 'DIRECT_SESSION_EXPIRED');
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.sessions.has(directSessionId))
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
- if (session !== undefined)
357
- void this.remove(session);
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 remove(session, code) {
360
- if (!this.sessions.delete(session.command.directSessionId))
411
+ async removeTransfer(transfer, code) {
412
+ if (!this.transfers.delete(transfer.command.directSessionId))
361
413
  return;
362
- if (session.leaseTimer !== undefined)
363
- clearInterval(session.leaseTimer);
364
- if (!session.uploadCommitted)
365
- await session.upload?.cancel().catch(() => undefined);
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: session.command.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 hash = createHash('sha256');
66
- let offset = 0;
67
- while (true) {
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 (range.nextOffset === null)
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 !== input.size || hash.digest('hex') !== input.sha256)
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
- const hash = createHash('sha256');
91
- let offset = 0;
92
- let size;
93
- while (true) {
94
- const range = (await this.readContent(workspace, { ...input, offset, limit: 512 * 1024 }, workspacePath));
95
- size ??= range.size;
96
- if (range.size !== size)
97
- throw new Error('DIRECT_FILE_REVISION_CHANGED');
98
- const bytes = Buffer.from(range.contentBase64, 'base64');
99
- hash.update(bytes);
100
- offset += bytes.length;
101
- if (range.nextOffset === null)
102
- break;
103
- if (range.nextOffset !== offset)
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
- if (size === undefined || offset !== size)
107
- throw new Error('DIRECT_FILE_REVISION_CHANGED');
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 { createHash, randomUUID } from 'node:crypto';
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, sha256) {
125
+ async completeDirect(uploadId, size) {
129
126
  const upload = this.require(uploadId);
130
- const expected = this.directHashes.get(uploadId);
131
- if (size !== upload.size || sha256 !== expected)
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.resumeOffset ?? 0,
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
- sha256: descriptor.sha256
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: descriptor.resource.path,
259
- commit: descriptor.resource.commit,
260
- ...(descriptor.resource.repositoryPath === undefined
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
- const content = await openGitHistoryFileContent(repository, descriptor.resource.commit, descriptor.resource.path, this.refs(input));
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.resumeOffset ?? 0,
272
+ offset: descriptor.range?.offset ?? 0,
273
273
  chunks: (async function* () {
274
- const hash = createHash('sha256');
275
- let offset = 0;
276
- for await (const bytes of content.chunks) {
277
- hash.update(bytes);
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 !== descriptor.size || hash.digest('hex') !== descriptor.sha256)
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 content = await openGitHistoryFileContent(repository, input.commit, input.path, this.refs(input));
292
- const hash = createHash('sha256');
293
- let offset = 0;
294
- for await (const bytes of content.chunks) {
295
- hash.update(bytes);
296
- offset += bytes.length;
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
- if (offset !== content.size)
299
- throw new Error('DIRECT_FILE_REVISION_CHANGED');
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.69",
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/agent": "0.9.69",
32
- "@myagentroam/protocol": "0.9.69"
31
+ "@myagentroam/protocol": "0.9.71",
32
+ "@myagentroam/agent": "0.9.71"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/ws": "^8.18.1"