@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.
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: 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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
50
|
-
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');
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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 >
|
|
69
|
-
throw new Error('
|
|
70
|
-
|
|
94
|
+
command.expiresAt > transfer.command.expiresAt)
|
|
95
|
+
throw new Error('DIRECT_TRANSFER_GRANT_INVALID');
|
|
96
|
+
transfer.grant = command;
|
|
71
97
|
return;
|
|
72
98
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
+
prepareTransport(command) {
|
|
83
120
|
if (command.nodeGeneration !== this.options.nodeGeneration())
|
|
84
121
|
throw new Error('DIRECT_NODE_GENERATION_MISMATCH');
|
|
85
|
-
if (this.
|
|
86
|
-
throw new Error('
|
|
87
|
-
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)
|
|
88
125
|
throw new Error('DIRECT_SESSION_LIMIT_REACHED');
|
|
89
|
-
const peer = this.createPeerConnection();
|
|
90
|
-
const
|
|
126
|
+
const peer = this.createPeerConnection(this.peerConfiguration);
|
|
127
|
+
const transport = {
|
|
91
128
|
command,
|
|
92
129
|
peer,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
130
|
+
sentCandidateKeys: new Set(),
|
|
131
|
+
lastLeaseAliveAt: this.now(),
|
|
132
|
+
leaseExpiresAt: command.expiresAt,
|
|
133
|
+
leaseTimer: undefined
|
|
97
134
|
};
|
|
98
|
-
this.
|
|
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,
|
|
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.
|
|
164
|
+
peer.ondatachannel = (event) => this.attachPendingChannel(transport, event.channel);
|
|
127
165
|
peer.onconnectionstatechange = () => {
|
|
128
166
|
if (peer.connectionState === 'connected')
|
|
129
|
-
this.emit(
|
|
167
|
+
this.emit(command.directSessionId, {
|
|
130
168
|
type: 'direct.connected',
|
|
131
|
-
directSessionId:
|
|
169
|
+
directSessionId: command.directSessionId
|
|
132
170
|
});
|
|
133
171
|
if (peer.connectionState === 'failed' || peer.connectionState === 'closed')
|
|
134
|
-
|
|
172
|
+
this.removeTransport(transport, 'DIRECT_CONNECTION_FAILED');
|
|
135
173
|
};
|
|
136
174
|
}
|
|
137
|
-
|
|
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('
|
|
141
|
-
await
|
|
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
|
|
208
|
+
await transport.peer.addIceCandidate(null);
|
|
146
209
|
return;
|
|
147
210
|
}
|
|
148
211
|
if (!hostOnlySdp(signal.sdp))
|
|
149
|
-
throw new Error('
|
|
150
|
-
await
|
|
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
|
|
157
|
-
await
|
|
158
|
-
const localSdp =
|
|
159
|
-
const
|
|
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(
|
|
226
|
+
this.emit(transport.command.directSessionId, {
|
|
165
227
|
type: 'direct.signal',
|
|
166
|
-
directSessionId:
|
|
228
|
+
directSessionId: transport.command.directSessionId,
|
|
167
229
|
signal: { kind: 'ANSWER', sdp, fingerprint: localFingerprint }
|
|
168
230
|
});
|
|
169
231
|
}
|
|
170
|
-
|
|
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
|
-
|
|
175
|
-
.then(() => this.channelMessage(
|
|
176
|
-
.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)));
|
|
177
268
|
};
|
|
178
269
|
channel.onclose = () => {
|
|
179
|
-
if (this.
|
|
180
|
-
void this.
|
|
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(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 !==
|
|
283
|
+
if (control.type !== 'ACK' || control.offset !== expectedSize)
|
|
210
284
|
throw new Error('DIRECT_FRAME_INVALID');
|
|
211
|
-
this.emit(
|
|
285
|
+
this.emit(transfer.command.directSessionId, {
|
|
212
286
|
type: 'direct.complete',
|
|
213
|
-
directSessionId:
|
|
214
|
-
size:
|
|
215
|
-
sha256:
|
|
287
|
+
directSessionId: transfer.command.directSessionId,
|
|
288
|
+
size: expectedSize,
|
|
289
|
+
sha256: expectedSha256
|
|
216
290
|
});
|
|
217
|
-
await this.
|
|
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' ||
|
|
296
|
+
if (control.type !== 'COMPLETE' || transfer.upload === undefined)
|
|
223
297
|
throw new Error('DIRECT_FRAME_INVALID');
|
|
224
|
-
if (control.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
|
|
228
|
-
|
|
229
|
-
this.emit(
|
|
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:
|
|
304
|
+
directSessionId: transfer.command.directSessionId,
|
|
232
305
|
size: control.size,
|
|
233
306
|
sha256: control.sha256
|
|
234
307
|
});
|
|
235
|
-
await this.
|
|
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 ||
|
|
312
|
+
if (bytes.byteLength === 0 || bytes.byteLength > 256 * 1024 || transfer.upload === undefined)
|
|
240
313
|
throw new Error('DIRECT_CHUNK_INVALID');
|
|
241
|
-
const offset = await
|
|
242
|
-
|
|
314
|
+
const offset = await transfer.upload.write(bytes);
|
|
315
|
+
transfer.channel?.send(JSON.stringify({ type: 'ACK', offset }));
|
|
243
316
|
}
|
|
244
|
-
async open(
|
|
245
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
this.emit(
|
|
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:
|
|
266
|
-
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
|
-
|
|
274
|
-
|
|
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:
|
|
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.
|
|
344
|
+
if (this.transfers.get(transfer.command.directSessionId) !== transfer)
|
|
282
345
|
return;
|
|
283
|
-
const channel =
|
|
346
|
+
const channel = transfer.channel;
|
|
284
347
|
if (channel === undefined)
|
|
285
348
|
throw new Error('DIRECT_CHANNEL_CLOSED');
|
|
286
|
-
await this.waitForSendCapacity(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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(
|
|
324
|
-
|
|
325
|
-
session.leaseTimer = setInterval(() => {
|
|
378
|
+
startLeaseWatch(expiresAt, lastAlive, expire) {
|
|
379
|
+
const timer = setInterval(() => {
|
|
326
380
|
const now = this.now();
|
|
327
|
-
if (
|
|
328
|
-
|
|
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.
|
|
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
|
-
|
|
343
|
-
|
|
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
|
|
346
|
-
if (!this.
|
|
415
|
+
async removeTransfer(transfer, code) {
|
|
416
|
+
if (!this.transfers.delete(transfer.command.directSessionId))
|
|
347
417
|
return;
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
259
|
-
commit:
|
|
260
|
-
...(
|
|
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
|
-
|
|
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.
|
|
273
|
+
offset: descriptor.range?.offset ?? 0,
|
|
273
274
|
chunks: (async function* () {
|
|
274
275
|
const hash = createHash('sha256');
|
|
275
|
-
let offset = 0;
|
|
276
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
32
|
-
"@myagentroam/protocol": "0.9.
|
|
31
|
+
"@myagentroam/agent": "0.9.70",
|
|
32
|
+
"@myagentroam/protocol": "0.9.70"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/ws": "^8.18.1"
|