@myagentroam/node 0.9.68 → 0.9.70

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: 2,
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
@@ -1,5 +1,5 @@
1
1
  import { dirname, resolve } from 'node:path';
2
- import { createEnvelope } from '@myagentroam/protocol';
2
+ import { createEnvelope, directNodeCommandSchema } from '@myagentroam/protocol';
3
3
  import { hostedConversationStorePath, loadNodeConfig, directTransferUdpPortRange, nodeDataDirectory, nodeDatabasePath } from './config.js';
4
4
  import { NodeMetrics, nodeLog } from './operational.js';
5
5
  import { assertLocalSqlitePath } from './storage.js';
@@ -1075,36 +1075,6 @@ export class NodeConnector {
1075
1075
  void this.terminalManager.shutdown();
1076
1076
  }
1077
1077
  });
1078
- this.directTransferService = new DirectTransferService({
1079
- nodeGeneration: () => this.nodeGeneration,
1080
- send: (type, payload) => this.send(type, payload),
1081
- udpPortRange: () => directTransferUdpPortRange(this.config),
1082
- openUpload: async (descriptor) => {
1083
- if (descriptor.resource.kind !== 'WORKSPACE_FILE')
1084
- throw new Error('DIRECT_RESOURCE_UNSUPPORTED');
1085
- const workspace = this.workspaceCoordinator.require(descriptor.resource.workspaceId);
1086
- const upload = await this.workspaceUploadService.createDirect(workspace, {
1087
- path: descriptor.resource.path,
1088
- size: descriptor.size,
1089
- sha256: descriptor.sha256,
1090
- overwrite: descriptor.overwrite === true,
1091
- ...(descriptor.composerAttachment === undefined
1092
- ? {}
1093
- : { composerAttachment: descriptor.composerAttachment })
1094
- });
1095
- return {
1096
- offset: upload.receivedBytes,
1097
- write: (bytes) => this.workspaceUploadService.writeDirect(upload.uploadId, bytes),
1098
- complete: (size, sha256) => this.workspaceUploadService.completeDirect(upload.uploadId, size, sha256),
1099
- cancel: () => this.workspaceUploadService.cancel(upload.uploadId)
1100
- };
1101
- },
1102
- openDownload: async (descriptor) => {
1103
- if (this.workspaceWorkbenchService === undefined)
1104
- throw new Error('DIRECT_RESOURCE_UNAVAILABLE');
1105
- return this.workspaceWorkbenchService.directDownload(descriptor);
1106
- }
1107
- });
1108
1078
  this.controlMessages = new NodeControlMessageService({
1109
1079
  config: () => this.config,
1110
1080
  register: (nodeId, credential) => this.connectionLifecycle.register(nodeId, credential),
@@ -1113,12 +1083,7 @@ export class NodeConnector {
1113
1083
  inspectWorkspace: (envelope) => this.workspaceCoordinator.inspect(envelope),
1114
1084
  handleNodeRequest: (envelope) => this.nodeRequests.handle(envelope),
1115
1085
  handleRuntimeCredentialResponse: (envelope) => this.runtimeCredentials.handle(envelope),
1116
- handleDirectTransfer: (envelope) => this.directTransferService.handle({
1117
- type: envelope.type,
1118
- ...(typeof envelope.payload === 'object' && envelope.payload !== null
1119
- ? envelope.payload
1120
- : {})
1121
- }),
1086
+ handleDirectTransfer: (envelope) => this.handleDirectTransfer(envelope),
1122
1087
  reconcileRuns: (envelope) => this.runEventBridge.reconcile(envelope),
1123
1088
  runners: this.runners,
1124
1089
  deliverSessionChannelMessage: (envelope) => this.runnerChannelMessages.handle(envelope),
@@ -1419,9 +1384,72 @@ export class NodeConnector {
1419
1384
  this.database ??= new NodeDatabase(nodeDatabasePath(this.config, this.configPath), () => this.config?.nodeId ?? 'unregistered');
1420
1385
  this.database.closeRunningTerminalSessions();
1421
1386
  await this.initializeConversationHosting();
1387
+ this.directTransferService ??= this.createDirectTransferService();
1422
1388
  this.connectionLifecycle.start();
1423
1389
  this.controlChannel.connect();
1424
1390
  }
1391
+ createDirectTransferService() {
1392
+ try {
1393
+ return new DirectTransferService({
1394
+ nodeGeneration: () => this.nodeGeneration,
1395
+ send: (type, payload) => this.send(type, payload),
1396
+ udpPortRange: () => directTransferUdpPortRange(this.config),
1397
+ openUpload: async (descriptor) => {
1398
+ if (descriptor.direction !== 'UPLOAD' || descriptor.resource.kind !== 'WORKSPACE_FILE')
1399
+ throw new Error('DIRECT_RESOURCE_UNSUPPORTED');
1400
+ const workspace = this.workspaceCoordinator.require(descriptor.resource.workspaceId);
1401
+ const upload = await this.workspaceUploadService.createDirect(workspace, {
1402
+ path: descriptor.resource.path,
1403
+ size: descriptor.size,
1404
+ sha256: descriptor.sha256,
1405
+ overwrite: descriptor.overwrite === true,
1406
+ ...(descriptor.composerAttachment === undefined
1407
+ ? {}
1408
+ : { composerAttachment: descriptor.composerAttachment })
1409
+ });
1410
+ return {
1411
+ offset: upload.receivedBytes,
1412
+ write: (bytes) => this.workspaceUploadService.writeDirect(upload.uploadId, bytes),
1413
+ complete: (size, sha256) => this.workspaceUploadService.completeDirect(upload.uploadId, size, sha256),
1414
+ cancel: () => this.workspaceUploadService.cancel(upload.uploadId)
1415
+ };
1416
+ },
1417
+ openDownload: async (descriptor) => {
1418
+ if (this.workspaceWorkbenchService === undefined)
1419
+ throw new Error('DIRECT_RESOURCE_UNAVAILABLE');
1420
+ return this.workspaceWorkbenchService.directDownload(descriptor);
1421
+ }
1422
+ });
1423
+ }
1424
+ catch (error) {
1425
+ if (!(error instanceof Error) || error.message !== 'DIRECT_UDP_PORT_UNAVAILABLE')
1426
+ throw error;
1427
+ const range = directTransferUdpPortRange(this.config);
1428
+ nodeLog('direct-transfer.udp-unavailable', {
1429
+ portRangeBegin: range.begin,
1430
+ portRangeEnd: range.end
1431
+ });
1432
+ return undefined;
1433
+ }
1434
+ }
1435
+ handleDirectTransfer(envelope) {
1436
+ const command = {
1437
+ type: envelope.type,
1438
+ ...(typeof envelope.payload === 'object' && envelope.payload !== null ? envelope.payload : {})
1439
+ };
1440
+ if (this.directTransferService !== undefined)
1441
+ return this.directTransferService.handle(command);
1442
+ const parsed = directNodeCommandSchema.safeParse(command);
1443
+ if (!parsed.success)
1444
+ return false;
1445
+ this.send('direct.error', {
1446
+ type: 'direct.error',
1447
+ directSessionId: parsed.data.directSessionId,
1448
+ code: 'DIRECT_UDP_PORT_UNAVAILABLE',
1449
+ message: 'The configured direct-transfer UDP port range is unavailable.'
1450
+ });
1451
+ return true;
1452
+ }
1425
1453
  clearMissionRetry(sessionId) {
1426
1454
  const retry = this.missionRetryTimers.get(sessionId);
1427
1455
  if (retry !== undefined)
@@ -1600,7 +1628,8 @@ export class NodeConnector {
1600
1628
  const terminalDrain = this.terminalManager.shutdown();
1601
1629
  this.terminalChannel.stop();
1602
1630
  this.runtimeCredentials.close();
1603
- this.directTransferService.close();
1631
+ this.directTransferService?.dispose();
1632
+ this.directTransferService = undefined;
1604
1633
  this.controlChannel.stop();
1605
1634
  const database = this.database;
1606
1635
  this.database = undefined;
@@ -1,31 +1,40 @@
1
1
  import { directDataControlFrameSchema, directNodeCommandSchema } from '@myagentroam/protocol';
2
+ import { IceUdpMuxListener as NativeIceUdpMuxListener } from 'node-datachannel';
2
3
  import { RTCPeerConnection } from 'node-datachannel/polyfill';
3
4
  const MAX_HOST_CANDIDATES = 32;
4
- const MAX_ACTIVE_SESSIONS = 32;
5
+ const MAX_ACTIVE_TRANSPORTS = 128;
6
+ const MAX_ACTIVE_TRANSFERS = 128;
5
7
  const DATA_CHANNEL_HIGH_WATER_BYTES = 4 * 1024 * 1024;
6
8
  const DATA_CHANNEL_LOW_WATER_BYTES = 1024 * 1024;
7
9
  const LEASE_GRACE_MS = 30_000;
8
10
  const LEASE_CHECK_MS = 5_000;
9
11
  export class DirectTransferService {
10
12
  options;
11
- sessions = new Map();
13
+ transports = new Map();
14
+ transfers = new Map();
12
15
  now;
13
16
  createPeerConnection;
17
+ iceUdpMuxListener;
18
+ peerConfiguration;
14
19
  leaseGraceMs;
15
20
  leaseCheckMs;
21
+ disposed = false;
16
22
  constructor(options) {
17
23
  this.options = options;
18
24
  this.now = options.now ?? Date.now;
25
+ const range = options.udpPortRange?.() ?? { begin: 49_152, end: 49_215 };
26
+ const createIceUdpMuxListener = options.createIceUdpMuxListener ?? ((port) => new NativeIceUdpMuxListener(port));
27
+ this.iceUdpMuxListener = bindIceUdpMuxListener(range, createIceUdpMuxListener);
28
+ const udpPort = this.iceUdpMuxListener.port();
29
+ this.peerConfiguration = {
30
+ iceServers: [],
31
+ enableIceUdpMux: true,
32
+ portRangeBegin: udpPort,
33
+ portRangeEnd: udpPort
34
+ };
19
35
  this.createPeerConnection =
20
36
  options.createPeerConnection ??
21
- (() => {
22
- const range = options.udpPortRange?.() ?? { begin: 49_152, end: 49_215 };
23
- return new RTCPeerConnection({
24
- iceServers: [],
25
- portRangeBegin: range.begin,
26
- portRangeEnd: range.end
27
- });
28
- });
37
+ ((configuration) => new RTCPeerConnection(configuration));
29
38
  this.leaseGraceMs = options.leaseGraceMs ?? LEASE_GRACE_MS;
30
39
  this.leaseCheckMs = options.leaseCheckMs ?? LEASE_CHECK_MS;
31
40
  }
@@ -46,56 +55,85 @@ export class DirectTransferService {
46
55
  return true;
47
56
  }
48
57
  close() {
49
- for (const session of [...this.sessions.values()])
50
- 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');
62
+ }
63
+ dispose() {
64
+ if (this.disposed)
65
+ return;
66
+ this.disposed = true;
67
+ this.close();
68
+ this.iceUdpMuxListener.stop();
51
69
  }
52
70
  async dispatch(command) {
53
71
  if (command.type === 'direct.prepare') {
54
- this.prepare(command);
72
+ if (command.purpose === 'TRANSPORT')
73
+ this.prepareTransport(command);
74
+ else
75
+ this.prepareTransfer(command);
55
76
  return;
56
77
  }
57
- const session = this.sessions.get(command.directSessionId);
58
- if (session === undefined)
59
- throw new Error('DIRECT_SESSION_INVALID');
60
78
  if (command.type === 'direct.signal') {
61
- 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);
62
83
  return;
63
84
  }
64
85
  if (command.type === 'direct.grant') {
65
- if (command.browserFingerprint !== fingerprint(session.peer.remoteDescription?.sdp) ||
66
- 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) ||
67
93
  command.openExpiresAt <= this.now() ||
68
- command.expiresAt > session.command.expiresAt)
69
- throw new Error('DIRECT_GRANT_INVALID');
70
- session.grant = command;
94
+ command.expiresAt > transfer.command.expiresAt)
95
+ throw new Error('DIRECT_TRANSFER_GRANT_INVALID');
96
+ transfer.grant = command;
71
97
  return;
72
98
  }
73
- if (command.type === 'direct.heartbeat') {
74
- if (session.command.expiresAt <= this.now())
75
- await this.remove(session, 'DIRECT_SESSION_EXPIRED');
76
- else
77
- 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);
78
107
  return;
79
108
  }
80
- 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);
81
118
  }
82
- prepare(command) {
119
+ prepareTransport(command) {
83
120
  if (command.nodeGeneration !== this.options.nodeGeneration())
84
121
  throw new Error('DIRECT_NODE_GENERATION_MISMATCH');
85
- if (this.sessions.has(command.directSessionId))
86
- throw new Error('DIRECT_SESSION_CONFLICT');
87
- 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)
88
125
  throw new Error('DIRECT_SESSION_LIMIT_REACHED');
89
- const peer = this.createPeerConnection();
90
- const session = {
126
+ const peer = this.createPeerConnection(this.peerConfiguration);
127
+ const transport = {
91
128
  command,
92
129
  peer,
93
- opened: false,
94
- uploadCommitted: false,
95
- processing: Promise.resolve(),
96
- sentCandidateKeys: new Set()
130
+ sentCandidateKeys: new Set(),
131
+ lastLeaseAliveAt: this.now(),
132
+ leaseExpiresAt: command.expiresAt,
133
+ leaseTimer: undefined
97
134
  };
98
- 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);
99
137
  peer.onicecandidate = (event) => {
100
138
  const candidate = event.candidate;
101
139
  if (candidate === null) {
@@ -108,7 +146,7 @@ export class DirectTransferService {
108
146
  }
109
147
  const json = candidate.toJSON();
110
148
  if (typeof json.candidate !== 'string' ||
111
- !acceptHostCandidate(json.candidate, session.sentCandidateKeys))
149
+ !acceptHostCandidate(json.candidate, transport.sentCandidateKeys))
112
150
  return;
113
151
  this.emit(command.directSessionId, {
114
152
  type: 'direct.signal',
@@ -123,178 +161,198 @@ export class DirectTransferService {
123
161
  }
124
162
  });
125
163
  };
126
- peer.ondatachannel = (event) => this.attachChannel(session, event.channel);
164
+ peer.ondatachannel = (event) => this.attachPendingChannel(transport, event.channel);
127
165
  peer.onconnectionstatechange = () => {
128
166
  if (peer.connectionState === 'connected')
129
- this.emit(session.command.directSessionId, {
167
+ this.emit(command.directSessionId, {
130
168
  type: 'direct.connected',
131
- directSessionId: session.command.directSessionId
169
+ directSessionId: command.directSessionId
132
170
  });
133
171
  if (peer.connectionState === 'failed' || peer.connectionState === 'closed')
134
- void this.remove(session, 'DIRECT_CONNECTION_FAILED');
172
+ this.removeTransport(transport, 'DIRECT_CONNECTION_FAILED');
135
173
  };
136
174
  }
137
- async signal(session, signal) {
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
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);
199
+ }
200
+ async signal(transport, signal) {
138
201
  if (signal.kind === 'CANDIDATE') {
139
202
  if (!safeHostCandidate(signal.candidate.candidate))
140
- throw new Error('DIRECT_CANDIDATE_REJECTED');
141
- await session.peer.addIceCandidate(signal.candidate);
203
+ throw new Error('DIRECT_TRANSFER_CANDIDATE_REJECTED');
204
+ await transport.peer.addIceCandidate(signal.candidate);
142
205
  return;
143
206
  }
144
207
  if (signal.kind === 'END_OF_CANDIDATES') {
145
- await session.peer.addIceCandidate(null);
208
+ await transport.peer.addIceCandidate(null);
146
209
  return;
147
210
  }
148
211
  if (!hostOnlySdp(signal.sdp))
149
- throw new Error('DIRECT_CANDIDATE_REJECTED');
150
- await session.peer.setRemoteDescription({
212
+ throw new Error('DIRECT_TRANSFER_CANDIDATE_REJECTED');
213
+ await transport.peer.setRemoteDescription({
151
214
  type: signal.kind === 'OFFER' ? 'offer' : 'answer',
152
215
  sdp: signal.sdp
153
216
  });
154
217
  if (signal.kind !== 'OFFER')
155
218
  return;
156
- const answer = await session.peer.createAnswer();
157
- await session.peer.setLocalDescription(answer);
158
- const localSdp = session.peer.localDescription?.sdp;
159
- const filtered = filterIceCandidates(localSdp, session.sentCandidateKeys);
160
- 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;
161
223
  const localFingerprint = fingerprint(localSdp);
162
224
  if (sdp === undefined || localFingerprint === undefined)
163
225
  throw new Error('DIRECT_FINGERPRINT_MISSING');
164
- this.emit(session.command.directSessionId, {
226
+ this.emit(transport.command.directSessionId, {
165
227
  type: 'direct.signal',
166
- directSessionId: session.command.directSessionId,
228
+ directSessionId: transport.command.directSessionId,
167
229
  signal: { kind: 'ANSWER', sdp, fingerprint: localFingerprint }
168
230
  });
169
231
  }
170
- attachChannel(session, channel) {
171
- session.channel = channel;
232
+ attachPendingChannel(transport, channel) {
172
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;
173
264
  channel.onmessage = (event) => {
174
- session.processing = session.processing
175
- .then(() => this.channelMessage(session, event.data))
176
- .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)));
177
268
  };
178
269
  channel.onclose = () => {
179
- if (this.sessions.get(session.command.directSessionId) === session)
180
- void this.remove(session, 'DIRECT_CHANNEL_CLOSED');
270
+ if (this.transfers.get(transfer.command.directSessionId) === transfer)
271
+ void this.removeTransfer(transfer, 'DIRECT_CHANNEL_CLOSED');
181
272
  };
273
+ await this.open(transfer);
182
274
  }
183
- async channelMessage(session, data) {
184
- if (!session.opened) {
185
- if (typeof data !== 'string')
186
- throw new Error('DIRECT_OPEN_REQUIRED');
187
- const parsed = directDataControlFrameSchema.parse(JSON.parse(data));
188
- if (parsed.type !== 'OPEN')
189
- throw new Error('DIRECT_OPEN_REQUIRED');
190
- const grant = session.grant;
191
- if (grant === undefined ||
192
- parsed.directSessionId !== session.command.directSessionId ||
193
- parsed.grant !== grant.grant ||
194
- grant.openExpiresAt <= this.now() ||
195
- grant.expiresAt <= this.now())
196
- throw new Error('DIRECT_GRANT_INVALID');
197
- session.opened = true;
198
- if (session.command.purpose === 'TRANSFER')
199
- this.startLeaseWatch(session);
200
- await this.open(session);
201
- return;
202
- }
203
- if (session.command.purpose !== 'TRANSFER' || session.command.transfer === undefined)
204
- throw new Error('DIRECT_FRAME_INVALID');
205
- if (session.command.transfer.direction === 'DOWNLOAD') {
275
+ async channelMessage(transfer, data) {
276
+ const descriptor = transfer.command.transfer;
277
+ const expectedSize = transferredSize(descriptor);
278
+ const expectedSha256 = transferredSha256(descriptor);
279
+ if (descriptor.direction === 'DOWNLOAD') {
206
280
  if (typeof data !== 'string')
207
281
  throw new Error('DIRECT_FRAME_INVALID');
208
282
  const control = directDataControlFrameSchema.parse(JSON.parse(data));
209
- if (control.type !== 'ACK' || control.offset !== session.command.transfer.size)
283
+ if (control.type !== 'ACK' || control.offset !== expectedSize)
210
284
  throw new Error('DIRECT_FRAME_INVALID');
211
- this.emit(session.command.directSessionId, {
285
+ this.emit(transfer.command.directSessionId, {
212
286
  type: 'direct.complete',
213
- directSessionId: session.command.directSessionId,
214
- size: session.command.transfer.size,
215
- sha256: session.command.transfer.sha256
287
+ directSessionId: transfer.command.directSessionId,
288
+ size: expectedSize,
289
+ sha256: expectedSha256
216
290
  });
217
- await this.remove(session);
291
+ await this.removeTransfer(transfer);
218
292
  return;
219
293
  }
220
294
  if (typeof data === 'string') {
221
295
  const control = directDataControlFrameSchema.parse(JSON.parse(data));
222
- if (control.type !== 'COMPLETE' || session.upload === undefined)
296
+ if (control.type !== 'COMPLETE' || transfer.upload === undefined)
223
297
  throw new Error('DIRECT_FRAME_INVALID');
224
- if (control.size !== session.command.transfer.size ||
225
- control.sha256 !== session.command.transfer.sha256)
298
+ if (control.size !== descriptor.size || control.sha256 !== descriptor.sha256)
226
299
  throw new Error('DIRECT_INTEGRITY_FAILED');
227
- await session.upload.complete(control.size, control.sha256);
228
- session.uploadCommitted = true;
229
- this.emit(session.command.directSessionId, {
300
+ await transfer.upload.complete(control.size, control.sha256);
301
+ transfer.uploadCommitted = true;
302
+ this.emit(transfer.command.directSessionId, {
230
303
  type: 'direct.complete',
231
- directSessionId: session.command.directSessionId,
304
+ directSessionId: transfer.command.directSessionId,
232
305
  size: control.size,
233
306
  sha256: control.sha256
234
307
  });
235
- await this.remove(session);
308
+ await this.removeTransfer(transfer);
236
309
  return;
237
310
  }
238
311
  const bytes = toBytes(data);
239
- if (bytes.byteLength === 0 || bytes.byteLength > 256 * 1024 || session.upload === undefined)
312
+ if (bytes.byteLength === 0 || bytes.byteLength > 256 * 1024 || transfer.upload === undefined)
240
313
  throw new Error('DIRECT_CHUNK_INVALID');
241
- const offset = await session.upload.write(bytes);
242
- session.channel?.send(JSON.stringify({ type: 'ACK', offset }));
314
+ const offset = await transfer.upload.write(bytes);
315
+ transfer.channel?.send(JSON.stringify({ type: 'ACK', offset }));
243
316
  }
244
- async open(session) {
245
- if (session.command.purpose === 'PROBE') {
246
- session.channel?.send(JSON.stringify({ type: 'ACK', offset: 0 }));
247
- this.emit(session.command.directSessionId, {
248
- type: 'direct.probe-result',
249
- directSessionId: session.command.directSessionId,
250
- result: 'DIRECT_AVAILABLE'
251
- });
252
- await this.remove(session);
253
- return;
254
- }
255
- const descriptor = session.command.transfer;
256
- if (descriptor === undefined)
257
- throw new Error('DIRECT_TRANSFER_INVALID');
317
+ async open(transfer) {
318
+ const descriptor = transfer.command.transfer;
258
319
  if (descriptor.direction === 'UPLOAD') {
259
320
  if (this.options.openUpload === undefined)
260
321
  throw new Error('DIRECT_UPLOAD_UNAVAILABLE');
261
- session.upload = await this.options.openUpload(descriptor);
262
- session.channel?.send(JSON.stringify({ type: 'ACK', offset: session.upload.offset }));
263
- this.emit(session.command.directSessionId, {
322
+ transfer.upload = await this.options.openUpload(descriptor);
323
+ transfer.channel?.send(JSON.stringify({ type: 'ACK', offset: transfer.upload.offset }));
324
+ this.emit(transfer.command.directSessionId, {
264
325
  type: 'direct.opened',
265
- directSessionId: session.command.directSessionId,
266
- offset: session.upload.offset
326
+ directSessionId: transfer.command.directSessionId,
327
+ offset: transfer.upload.offset
267
328
  });
268
329
  return;
269
330
  }
270
331
  if (this.options.openDownload === undefined)
271
332
  throw new Error('DIRECT_DOWNLOAD_UNAVAILABLE');
272
333
  const source = await this.options.openDownload(descriptor);
273
- session.channel?.send(JSON.stringify({ type: 'ACK', offset: source.offset }));
274
- this.emit(session.command.directSessionId, {
334
+ const expectedSize = transferredSize(descriptor);
335
+ const expectedSha256 = transferredSha256(descriptor);
336
+ transfer.channel?.send(JSON.stringify({ type: 'ACK', offset: source.offset }));
337
+ this.emit(transfer.command.directSessionId, {
275
338
  type: 'direct.opened',
276
- directSessionId: session.command.directSessionId,
339
+ directSessionId: transfer.command.directSessionId,
277
340
  offset: source.offset
278
341
  });
279
- for await (const chunk of source.chunks) {
342
+ for await (const chunk of source.chunks)
280
343
  for (let offset = 0; offset < chunk.byteLength; offset += 256 * 1024) {
281
- if (this.sessions.get(session.command.directSessionId) !== session)
344
+ if (this.transfers.get(transfer.command.directSessionId) !== transfer)
282
345
  return;
283
- const channel = session.channel;
346
+ const channel = transfer.channel;
284
347
  if (channel === undefined)
285
348
  throw new Error('DIRECT_CHANNEL_CLOSED');
286
- await this.waitForSendCapacity(session, channel);
349
+ await this.waitForSendCapacity(transfer, channel);
287
350
  const end = Math.min(chunk.byteLength, offset + 256 * 1024);
288
351
  channel.send(chunk.buffer.slice(chunk.byteOffset + offset, chunk.byteOffset + end));
289
352
  }
290
- }
291
- session.channel?.send(JSON.stringify({
292
- type: 'COMPLETE',
293
- size: descriptor.size,
294
- sha256: descriptor.sha256
295
- }));
353
+ transfer.channel?.send(JSON.stringify({ type: 'COMPLETE', size: expectedSize, sha256: expectedSha256 }));
296
354
  }
297
- async waitForSendCapacity(session, channel) {
355
+ async waitForSendCapacity(transfer, channel) {
298
356
  if (channel.readyState !== 'open')
299
357
  throw new Error('DIRECT_CHANNEL_CLOSED');
300
358
  if (channel.bufferedAmount <= DATA_CHANNEL_HIGH_WATER_BYTES)
@@ -304,62 +362,105 @@ export class DirectTransferService {
304
362
  const complete = (error) => {
305
363
  channel.removeEventListener('bufferedamountlow', available);
306
364
  channel.removeEventListener('close', closed);
307
- if (error === undefined)
308
- resolve();
309
- else
310
- reject(error);
365
+ error === undefined ? resolve() : reject(error);
311
366
  };
312
367
  const available = () => complete();
313
368
  const closed = () => complete(new Error('DIRECT_CHANNEL_CLOSED'));
314
369
  channel.addEventListener('bufferedamountlow', available, { once: true });
315
370
  channel.addEventListener('close', closed, { once: true });
316
- if (this.sessions.get(session.command.directSessionId) !== session ||
371
+ if (this.transfers.get(transfer.command.directSessionId) !== transfer ||
317
372
  channel.readyState !== 'open')
318
373
  closed();
319
374
  else if (channel.bufferedAmount <= DATA_CHANNEL_LOW_WATER_BYTES)
320
375
  available();
321
376
  });
322
377
  }
323
- startLeaseWatch(session) {
324
- session.lastLeaseAliveAt = this.now();
325
- session.leaseTimer = setInterval(() => {
378
+ startLeaseWatch(expiresAt, lastAlive, expire) {
379
+ const timer = setInterval(() => {
326
380
  const now = this.now();
327
- if (session.command.expiresAt <= now) {
328
- void this.remove(session, 'DIRECT_SESSION_EXPIRED');
329
- return;
330
- }
331
- if (now - (session.lastLeaseAliveAt ?? now) > this.leaseGraceMs)
332
- void this.remove(session, 'DIRECT_LEASE_EXPIRED');
381
+ if (expiresAt() <= now || now - lastAlive() > this.leaseGraceMs)
382
+ expire();
333
383
  }, this.leaseCheckMs);
384
+ return timer;
334
385
  }
335
386
  emit(directSessionId, event) {
336
- if (this.sessions.has(directSessionId))
387
+ if (this.transports.has(directSessionId) || this.transfers.has(directSessionId))
337
388
  this.options.send(event.type, event);
338
389
  }
339
390
  fail(directSessionId, code, message) {
340
- const session = this.sessions.get(directSessionId);
341
391
  this.options.send('direct.error', { type: 'direct.error', directSessionId, code, message });
342
- if (session !== undefined)
343
- void this.remove(session);
392
+ const transfer = this.transfers.get(directSessionId);
393
+ if (transfer !== undefined)
394
+ void this.removeTransfer(transfer);
395
+ const transport = this.transports.get(directSessionId);
396
+ if (transport !== undefined)
397
+ this.removeTransport(transport);
398
+ }
399
+ removeTransport(transport, code) {
400
+ if (!this.transports.delete(transport.command.directSessionId))
401
+ return;
402
+ clearInterval(transport.leaseTimer);
403
+ for (const transfer of [...this.transfers.values()])
404
+ if (transfer.command.transportSessionId === transport.command.directSessionId)
405
+ void this.removeTransfer(transfer, code ?? 'DIRECT_CONNECTION_FAILED');
406
+ transport.peer.close();
407
+ if (code !== undefined)
408
+ this.options.send('direct.error', {
409
+ type: 'direct.error',
410
+ directSessionId: transport.command.directSessionId,
411
+ code,
412
+ message: directErrorMessage(code)
413
+ });
344
414
  }
345
- async remove(session, code) {
346
- if (!this.sessions.delete(session.command.directSessionId))
415
+ async removeTransfer(transfer, code) {
416
+ if (!this.transfers.delete(transfer.command.directSessionId))
347
417
  return;
348
- if (session.leaseTimer !== undefined)
349
- clearInterval(session.leaseTimer);
350
- if (!session.uploadCommitted)
351
- await session.upload?.cancel().catch(() => undefined);
352
- session.channel?.close();
353
- session.peer.close();
418
+ clearInterval(transfer.leaseTimer);
419
+ if (!transfer.uploadCommitted)
420
+ await transfer.upload?.cancel().catch(() => undefined);
421
+ transfer.channel?.close();
354
422
  if (code !== undefined)
355
423
  this.options.send('direct.error', {
356
424
  type: 'direct.error',
357
- directSessionId: session.command.directSessionId,
425
+ directSessionId: transfer.command.directSessionId,
358
426
  code,
359
427
  message: directErrorMessage(code)
360
428
  });
361
429
  }
362
430
  }
431
+ function transferredSize(descriptor) {
432
+ return descriptor.direction === 'DOWNLOAD' && descriptor.range !== undefined
433
+ ? descriptor.range.length
434
+ : descriptor.size;
435
+ }
436
+ function transferredSha256(descriptor) {
437
+ return descriptor.direction === 'DOWNLOAD' && descriptor.range !== undefined
438
+ ? descriptor.range.sha256
439
+ : descriptor.sha256;
440
+ }
441
+ function openFrameSessionId(data) {
442
+ if (typeof data !== 'string')
443
+ return undefined;
444
+ try {
445
+ const value = JSON.parse(data);
446
+ return typeof value.directSessionId === 'string' ? value.directSessionId : undefined;
447
+ }
448
+ catch {
449
+ return undefined;
450
+ }
451
+ }
452
+ function bindIceUdpMuxListener(range, create) {
453
+ let lastError;
454
+ for (let port = range.begin; port <= range.end; port += 1) {
455
+ try {
456
+ return create(port);
457
+ }
458
+ catch (error) {
459
+ lastError = error;
460
+ }
461
+ }
462
+ throw new Error('DIRECT_UDP_PORT_UNAVAILABLE', { cause: lastError });
463
+ }
363
464
  export function fingerprint(sdp) {
364
465
  const match = sdp?.match(/^a=fingerprint:(sha-256 [A-F0-9:]+)\r?$/imu);
365
466
  return match?.[1];
@@ -63,37 +63,78 @@ export class WorkspaceFileService {
63
63
  const service = this;
64
64
  return (async function* () {
65
65
  const hash = createHash('sha256');
66
- let offset = 0;
67
- while (true) {
66
+ let offset = input.range?.offset ?? 0;
67
+ const endOffset = input.range === undefined ? input.size : input.range.offset + input.range.length;
68
+ while (offset < endOffset) {
68
69
  const range = (await service.readContent(workspace, {
69
70
  path: input.path,
70
71
  offset,
71
- limit: 512 * 1024
72
+ limit: Math.min(512 * 1024, endOffset - offset)
72
73
  }, workspacePath));
73
- if (range.size !== input.size)
74
+ if (range.size !== input.size || range.sourceRevision !== input.revision)
74
75
  throw new Error('DIRECT_FILE_REVISION_CHANGED');
75
76
  const bytes = Buffer.from(range.contentBase64, 'base64');
76
77
  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
+ const expectedHash = input.range?.sha256 ?? input.sha256;
87
+ if (offset !== endOffset || hash.digest('hex') !== expectedHash)
86
88
  throw new Error('DIRECT_FILE_REVISION_CHANGED');
87
89
  })();
88
90
  }
89
91
  async directMetadata(workspace, input, workspacePath = workspace.path) {
92
+ if (input.offset !== undefined ||
93
+ input.length !== undefined ||
94
+ input.size !== undefined ||
95
+ input.sha256 !== undefined) {
96
+ if (!Number.isSafeInteger(input.offset) ||
97
+ input.offset < 0 ||
98
+ !Number.isSafeInteger(input.length) ||
99
+ input.length < 1 ||
100
+ !Number.isSafeInteger(input.size) ||
101
+ input.size < 0 ||
102
+ typeof input.sha256 !== 'string' ||
103
+ !/^[a-f0-9]{64}$/u.test(input.sha256) ||
104
+ typeof input.revision !== 'string' ||
105
+ !/^[a-f0-9]{64}$/u.test(input.revision))
106
+ throw new Error('FILE_RANGE_INVALID');
107
+ const offset = input.offset;
108
+ const length = input.length;
109
+ const expectedSize = input.size;
110
+ if (offset + length > expectedSize)
111
+ throw new Error('FILE_RANGE_INVALID');
112
+ const read = (await this.readContent(workspace, { ...input, offset, limit: length }, workspacePath));
113
+ const bytes = Buffer.from(read.contentBase64, 'base64');
114
+ if (read.size !== expectedSize ||
115
+ read.sourceRevision !== input.revision ||
116
+ bytes.length !== length)
117
+ throw new Error('DIRECT_FILE_REVISION_CHANGED');
118
+ return {
119
+ size: expectedSize,
120
+ sha256: input.sha256,
121
+ revision: input.revision,
122
+ range: {
123
+ offset,
124
+ length,
125
+ sha256: createHash('sha256').update(bytes).digest('hex')
126
+ }
127
+ };
128
+ }
90
129
  const hash = createHash('sha256');
91
130
  let offset = 0;
92
131
  let size;
132
+ let revision;
93
133
  while (true) {
94
134
  const range = (await this.readContent(workspace, { ...input, offset, limit: 512 * 1024 }, workspacePath));
95
135
  size ??= range.size;
96
- if (range.size !== size)
136
+ revision ??= range.sourceRevision;
137
+ if (range.size !== size || range.sourceRevision !== revision)
97
138
  throw new Error('DIRECT_FILE_REVISION_CHANGED');
98
139
  const bytes = Buffer.from(range.contentBase64, 'base64');
99
140
  hash.update(bytes);
@@ -103,9 +144,10 @@ export class WorkspaceFileService {
103
144
  if (range.nextOffset !== offset)
104
145
  throw new Error('DIRECT_FILE_REVISION_CHANGED');
105
146
  }
106
- if (size === undefined || offset !== size)
147
+ if (size === undefined || revision === undefined || offset !== size)
107
148
  throw new Error('DIRECT_FILE_REVISION_CHANGED');
108
- return { size, sha256: hash.digest('hex') };
149
+ const sha256 = hash.digest('hex');
150
+ return { size, sha256, revision };
109
151
  }
110
152
  async write(workspace, input) {
111
153
  if (typeof input.path !== 'string' ||
@@ -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,51 @@ 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
+ sha256: descriptor.sha256,
255
+ revision: descriptor.revision,
256
+ ...(descriptor.range === undefined ? {} : { range: descriptor.range })
253
257
  }, await this.fileRepository(workspace, input))
254
258
  };
255
259
  }
260
+ const resource = descriptor.resource;
256
261
  const input = {
257
262
  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 })
263
+ path: resource.path,
264
+ commit: resource.commit,
265
+ ...(resource.repositoryPath === undefined ? {} : { repositoryPath: resource.repositoryPath }),
266
+ ...(resource.worktreePath === undefined ? {} : { worktreePath: resource.worktreePath })
266
267
  };
267
268
  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)
269
+ if (descriptor.revision !== gitResourceRevision(resource.commit, resource.path))
270
270
  throw new Error('DIRECT_FILE_REVISION_CHANGED');
271
+ const refs = this.refs(input);
271
272
  return {
272
- offset: descriptor.resumeOffset ?? 0,
273
+ offset: descriptor.range?.offset ?? 0,
273
274
  chunks: (async function* () {
274
275
  const hash = createHash('sha256');
275
- let offset = 0;
276
- for await (const bytes of content.chunks) {
276
+ let offset = descriptor.range?.offset ?? 0;
277
+ const rangeStart = descriptor.range?.offset ?? 0;
278
+ const rangeEnd = rangeStart + (descriptor.range?.length ?? descriptor.size);
279
+ while (offset < rangeEnd) {
280
+ const range = await readGitHistoryFileContentRange(repository, resource.commit, resource.path, offset, Math.min(512 * 1024, rangeEnd - offset), refs);
281
+ if (range.size !== descriptor.size)
282
+ throw new Error('DIRECT_FILE_REVISION_CHANGED');
283
+ const bytes = Buffer.from(range.contentBase64, 'base64');
277
284
  hash.update(bytes);
278
285
  offset += bytes.length;
279
286
  if (bytes.length > 0)
280
287
  yield bytes;
288
+ if (offset === rangeEnd)
289
+ break;
290
+ if (range.nextOffset !== offset)
291
+ throw new Error('DIRECT_FILE_REVISION_CHANGED');
281
292
  }
282
- if (offset !== descriptor.size || hash.digest('hex') !== descriptor.sha256)
293
+ const expectedHash = descriptor.range?.sha256 ?? descriptor.sha256;
294
+ if (offset !== rangeEnd || hash.digest('hex') !== expectedHash)
283
295
  throw new Error('DIRECT_FILE_REVISION_CHANGED');
284
296
  })()
285
297
  };
@@ -288,6 +300,43 @@ export class WorkspaceWorkbenchService {
288
300
  const repository = await this.gitRepository(input);
289
301
  if (typeof input.commit !== 'string' || typeof input.path !== 'string')
290
302
  throw new Error('GIT_HISTORY_COMMIT_INVALID');
303
+ const revision = gitResourceRevision(input.commit, input.path);
304
+ if (input.offset !== undefined ||
305
+ input.length !== undefined ||
306
+ input.size !== undefined ||
307
+ input.sha256 !== undefined ||
308
+ input.revision !== undefined) {
309
+ if (!Number.isSafeInteger(input.offset) ||
310
+ input.offset < 0 ||
311
+ !Number.isSafeInteger(input.length) ||
312
+ input.length < 1 ||
313
+ input.length > 512 * 1024 ||
314
+ !Number.isSafeInteger(input.size) ||
315
+ input.size < 0 ||
316
+ typeof input.sha256 !== 'string' ||
317
+ !/^[a-f0-9]{64}$/u.test(input.sha256) ||
318
+ input.revision !== revision)
319
+ throw new Error('FILE_RANGE_INVALID');
320
+ const offset = input.offset;
321
+ const length = input.length;
322
+ const size = input.size;
323
+ if (offset + length > size)
324
+ throw new Error('FILE_RANGE_INVALID');
325
+ const range = await readGitHistoryFileContentRange(repository, input.commit, input.path, offset, length, this.refs(input));
326
+ const bytes = Buffer.from(range.contentBase64, 'base64');
327
+ if (range.size !== size || bytes.length !== length)
328
+ throw new Error('DIRECT_FILE_REVISION_CHANGED');
329
+ return {
330
+ size,
331
+ sha256: input.sha256,
332
+ revision,
333
+ range: {
334
+ offset,
335
+ length,
336
+ sha256: createHash('sha256').update(bytes).digest('hex')
337
+ }
338
+ };
339
+ }
291
340
  const content = await openGitHistoryFileContent(repository, input.commit, input.path, this.refs(input));
292
341
  const hash = createHash('sha256');
293
342
  let offset = 0;
@@ -297,7 +346,7 @@ export class WorkspaceWorkbenchService {
297
346
  }
298
347
  if (offset !== content.size)
299
348
  throw new Error('DIRECT_FILE_REVISION_CHANGED');
300
- return { size: content.size, sha256: hash.digest('hex') };
349
+ return { size: content.size, sha256: hash.digest('hex'), revision };
301
350
  }
302
351
  async diff(input) {
303
352
  if (typeof input.workspaceId !== 'string' ||
@@ -314,6 +363,9 @@ export class WorkspaceWorkbenchService {
314
363
  return workspace;
315
364
  }
316
365
  }
366
+ function gitResourceRevision(commit, path) {
367
+ return createHash('sha256').update(commit).update('\0').update(path).digest('hex');
368
+ }
317
369
  function record(value) {
318
370
  return typeof value === 'object' && value !== null && !Array.isArray(value)
319
371
  ? 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.68",
3
+ "version": "0.9.70",
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.68",
32
- "@myagentroam/protocol": "0.9.68"
31
+ "@myagentroam/agent": "0.9.70",
32
+ "@myagentroam/protocol": "0.9.70"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/ws": "^8.18.1"