@myagentroam/node 0.9.67 → 0.9.69
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 +14 -3
- package/dist/config.js +33 -1
- package/dist/connector.js +99 -6
- package/dist/runner/mar-agent/history-projection.js +2 -1
- package/dist/service/continuous-run-coordinator.js +1 -1
- package/dist/service/direct-transfer-service.js +479 -0
- package/dist/service/mission-runtime.js +10 -0
- package/dist/service/node-connection-lifecycle-service.js +1 -0
- package/dist/service/node-control-message-service.js +3 -0
- package/dist/service/session-message-service.js +3 -0
- package/dist/service/workbench-manifest-service.js +2 -0
- package/dist/service/workspace-file-service.js +49 -0
- package/dist/service/workspace-upload-service.js +53 -3
- package/dist/service/workspace-workbench-service.js +83 -1
- package/dist/workspace.js +61 -0
- package/package.json +4 -3
package/dist/capabilities.js
CHANGED
|
@@ -10,6 +10,14 @@ const runtimeCredentialCapability = () => ({
|
|
|
10
10
|
websocket: true,
|
|
11
11
|
httpVersions: ['1.1', '2']
|
|
12
12
|
});
|
|
13
|
+
const workspaceDirectTransferCapability = () => ({
|
|
14
|
+
apiVersion: 1,
|
|
15
|
+
probe: true,
|
|
16
|
+
upload: true,
|
|
17
|
+
download: true,
|
|
18
|
+
gitCommitBlob: true,
|
|
19
|
+
maxChunkBytes: 256 * 1024
|
|
20
|
+
});
|
|
13
21
|
export function reportedPlatform(platform = process.platform) {
|
|
14
22
|
if (platform === 'win32')
|
|
15
23
|
return 'windows';
|
|
@@ -37,7 +45,8 @@ export function unavailableCapabilities() {
|
|
|
37
45
|
claudeCode: { available: false },
|
|
38
46
|
openCode: { available: false },
|
|
39
47
|
marAgent: { available: true, version: nodeAppVersion },
|
|
40
|
-
marAgentRuntimeCredentials: runtimeCredentialCapability()
|
|
48
|
+
marAgentRuntimeCredentials: runtimeCredentialCapability(),
|
|
49
|
+
workspaceDirectTransfer: workspaceDirectTransferCapability()
|
|
41
50
|
};
|
|
42
51
|
}
|
|
43
52
|
export function detectCapabilities(commands = {}) {
|
|
@@ -69,7 +78,8 @@ export function detectCapabilities(commands = {}) {
|
|
|
69
78
|
...(openCode.version === undefined ? {} : { version: openCode.version })
|
|
70
79
|
},
|
|
71
80
|
marAgent: { available: true, version: nodeAppVersion },
|
|
72
|
-
marAgentRuntimeCredentials: runtimeCredentialCapability()
|
|
81
|
+
marAgentRuntimeCredentials: runtimeCredentialCapability(),
|
|
82
|
+
workspaceDirectTransfer: workspaceDirectTransferCapability()
|
|
73
83
|
};
|
|
74
84
|
}
|
|
75
85
|
export async function detectCapabilitiesAsync(commands = {}) {
|
|
@@ -105,6 +115,7 @@ export async function detectCapabilitiesAsync(commands = {}) {
|
|
|
105
115
|
...(openCode.version === undefined ? {} : { version: openCode.version })
|
|
106
116
|
},
|
|
107
117
|
marAgent: { available: true, version: nodeAppVersion },
|
|
108
|
-
marAgentRuntimeCredentials: runtimeCredentialCapability()
|
|
118
|
+
marAgentRuntimeCredentials: runtimeCredentialCapability(),
|
|
119
|
+
workspaceDirectTransfer: workspaceDirectTransferCapability()
|
|
109
120
|
};
|
|
110
121
|
}
|
package/dist/config.js
CHANGED
|
@@ -26,6 +26,7 @@ export async function loadNodeConfig(path = nodeConfigPath()) {
|
|
|
26
26
|
? input['allowedRoots']
|
|
27
27
|
: defaultAllowedRoots();
|
|
28
28
|
const conversationHosting = parseConversationHosting(input['conversationHosting']);
|
|
29
|
+
const directTransfer = parseDirectTransfer(input['directTransfer']);
|
|
29
30
|
return {
|
|
30
31
|
serverUrl: parsed.serverUrl,
|
|
31
32
|
allowedRoots,
|
|
@@ -33,7 +34,15 @@ export async function loadNodeConfig(path = nodeConfigPath()) {
|
|
|
33
34
|
...(credential === undefined ? {} : { credential }),
|
|
34
35
|
...(registrationToken === undefined ? {} : { registrationToken }),
|
|
35
36
|
...(dataDirectory === undefined ? {} : { dataDirectory }),
|
|
36
|
-
...(conversationHosting === undefined ? {} : { conversationHosting })
|
|
37
|
+
...(conversationHosting === undefined ? {} : { conversationHosting }),
|
|
38
|
+
...(directTransfer === undefined ? {} : { directTransfer })
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function directTransferUdpPortRange(config) {
|
|
42
|
+
const directTransfer = config?.directTransfer;
|
|
43
|
+
return {
|
|
44
|
+
begin: directTransfer?.udpPortRangeBegin ?? 49_152,
|
|
45
|
+
end: directTransfer?.udpPortRangeEnd ?? 49_215
|
|
37
46
|
};
|
|
38
47
|
}
|
|
39
48
|
/** Uses the Node service account's scope when no Workspace roots are configured. */
|
|
@@ -97,6 +106,29 @@ function parseConversationHosting(value) {
|
|
|
97
106
|
inactiveRetentionDays: inactiveRetentionDays
|
|
98
107
|
};
|
|
99
108
|
}
|
|
109
|
+
function parseDirectTransfer(value) {
|
|
110
|
+
if (value === undefined)
|
|
111
|
+
return undefined;
|
|
112
|
+
if (typeof value !== 'object' ||
|
|
113
|
+
value === null ||
|
|
114
|
+
Array.isArray(value) ||
|
|
115
|
+
Object.keys(value).some((key) => key !== 'udpPortRangeBegin' && key !== 'udpPortRangeEnd'))
|
|
116
|
+
throw new Error('NODE_CONFIG_INVALID');
|
|
117
|
+
const input = value;
|
|
118
|
+
const begin = input['udpPortRangeBegin'];
|
|
119
|
+
const end = input['udpPortRangeEnd'];
|
|
120
|
+
if (!Number.isInteger(begin) ||
|
|
121
|
+
!Number.isInteger(end) ||
|
|
122
|
+
begin < 1_024 ||
|
|
123
|
+
end > 65_535 ||
|
|
124
|
+
begin > end ||
|
|
125
|
+
end - begin + 1 > 256)
|
|
126
|
+
throw new Error('NODE_CONFIG_INVALID');
|
|
127
|
+
return {
|
|
128
|
+
udpPortRangeBegin: begin,
|
|
129
|
+
udpPortRangeEnd: end
|
|
130
|
+
};
|
|
131
|
+
}
|
|
100
132
|
function validStoreDirectory(value) {
|
|
101
133
|
if (typeof value !== 'string' || value.trim() !== value || value.length === 0)
|
|
102
134
|
return false;
|
package/dist/connector.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { dirname, resolve } from 'node:path';
|
|
2
|
-
import { createEnvelope } from '@myagentroam/protocol';
|
|
3
|
-
import { hostedConversationStorePath, loadNodeConfig, nodeDataDirectory, nodeDatabasePath } from './config.js';
|
|
2
|
+
import { createEnvelope, directNodeCommandSchema } from '@myagentroam/protocol';
|
|
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';
|
|
6
6
|
import { detectCapabilitiesAsync, unavailableCapabilities } from './capabilities.js';
|
|
@@ -32,6 +32,7 @@ import { WorkspaceService } from './service/workspace-service.js';
|
|
|
32
32
|
import { WorkspaceWatchService } from './service/workspace-watch-service.js';
|
|
33
33
|
import { WorkspaceFileService } from './service/workspace-file-service.js';
|
|
34
34
|
import { WorkspaceUploadService } from './service/workspace-upload-service.js';
|
|
35
|
+
import { DirectTransferService } from './service/direct-transfer-service.js';
|
|
35
36
|
import { WorkspaceChangeService } from './service/workspace-change-service.js';
|
|
36
37
|
import { WorkspaceSessionService } from './service/workspace-session-service.js';
|
|
37
38
|
import { WorkbenchEventService } from './service/workbench-event-service.js';
|
|
@@ -271,6 +272,7 @@ export class NodeConnector {
|
|
|
271
272
|
this.workspaceFileService.dispose(workspaceId);
|
|
272
273
|
this.workspaceCoordinator.invalidate(workspaceId);
|
|
273
274
|
});
|
|
275
|
+
directTransferService;
|
|
274
276
|
workspaceChangeService = new WorkspaceChangeService(this.workspaceState);
|
|
275
277
|
workspaceWorkbenchService;
|
|
276
278
|
database;
|
|
@@ -668,11 +670,13 @@ export class NodeConnector {
|
|
|
668
670
|
return;
|
|
669
671
|
const updated = this.missionRuntime.recordTurn(input.sessionId);
|
|
670
672
|
const session = this.runtime.getAgentSession(input.sessionId);
|
|
671
|
-
|
|
672
|
-
this.publishMissionState(session, updated);
|
|
673
|
+
const run = this.runtime.getRun(input.runId);
|
|
673
674
|
await this.enqueueMissionContinuation({
|
|
674
675
|
sessionId: input.sessionId,
|
|
675
676
|
mission: { id: mission.id, revision: mission.revision },
|
|
677
|
+
...(run?.initiatedByUserId === undefined
|
|
678
|
+
? {}
|
|
679
|
+
: { initiatedByUserId: run.initiatedByUserId }),
|
|
676
680
|
secretEnvironment: input.secretEnvironment,
|
|
677
681
|
...(input.personalInstructions === undefined
|
|
678
682
|
? {}
|
|
@@ -683,6 +687,17 @@ export class NodeConnector {
|
|
|
683
687
|
? {}
|
|
684
688
|
: { marAgentRuntimeScopeId: input.marAgentRuntimeScopeId })
|
|
685
689
|
});
|
|
690
|
+
if (updated !== undefined && session !== undefined)
|
|
691
|
+
this.publishMissionState(session, updated);
|
|
692
|
+
},
|
|
693
|
+
afterExecutionStartFailed: (input, error) => {
|
|
694
|
+
nodeLog('mission.execution-start.failed', {
|
|
695
|
+
sessionId: input.sessionId,
|
|
696
|
+
runId: input.runId,
|
|
697
|
+
missionId: input.mission?.id,
|
|
698
|
+
revision: input.mission?.revision,
|
|
699
|
+
code: safeErrorCode(error, 'MISSION_EXECUTION_START_FAILED')
|
|
700
|
+
});
|
|
686
701
|
},
|
|
687
702
|
missionMcp: async (input) => {
|
|
688
703
|
if (input.mission === undefined)
|
|
@@ -1049,6 +1064,7 @@ export class NodeConnector {
|
|
|
1049
1064
|
onReconnectScheduled: () => this.metrics.reconnectScheduled(),
|
|
1050
1065
|
onMessage: (raw) => this.handleMessage(raw),
|
|
1051
1066
|
onClose: (code, reason) => {
|
|
1067
|
+
this.directTransferService?.close();
|
|
1052
1068
|
this.runtimeCredentials.disconnected();
|
|
1053
1069
|
this.terminalChannel.disconnect();
|
|
1054
1070
|
if (code === 4000 && reason === 'NODE_CONNECTION_REPLACED')
|
|
@@ -1067,6 +1083,7 @@ export class NodeConnector {
|
|
|
1067
1083
|
inspectWorkspace: (envelope) => this.workspaceCoordinator.inspect(envelope),
|
|
1068
1084
|
handleNodeRequest: (envelope) => this.nodeRequests.handle(envelope),
|
|
1069
1085
|
handleRuntimeCredentialResponse: (envelope) => this.runtimeCredentials.handle(envelope),
|
|
1086
|
+
handleDirectTransfer: (envelope) => this.handleDirectTransfer(envelope),
|
|
1070
1087
|
reconcileRuns: (envelope) => this.runEventBridge.reconcile(envelope),
|
|
1071
1088
|
runners: this.runners,
|
|
1072
1089
|
deliverSessionChannelMessage: (envelope) => this.runnerChannelMessages.handle(envelope),
|
|
@@ -1134,6 +1151,7 @@ export class NodeConnector {
|
|
|
1134
1151
|
this.runnerService = new RunnerService(requireDatabase, () => this.capabilities, this.runners, this.runnerUsageReader);
|
|
1135
1152
|
this.workbenchManifestService = new WorkbenchManifestService({
|
|
1136
1153
|
capabilities: () => this.capabilities,
|
|
1154
|
+
nodeGeneration: () => this.nodeGeneration,
|
|
1137
1155
|
runners: this.runnerService,
|
|
1138
1156
|
terminals: this.terminalManager
|
|
1139
1157
|
});
|
|
@@ -1366,9 +1384,72 @@ export class NodeConnector {
|
|
|
1366
1384
|
this.database ??= new NodeDatabase(nodeDatabasePath(this.config, this.configPath), () => this.config?.nodeId ?? 'unregistered');
|
|
1367
1385
|
this.database.closeRunningTerminalSessions();
|
|
1368
1386
|
await this.initializeConversationHosting();
|
|
1387
|
+
this.directTransferService ??= this.createDirectTransferService();
|
|
1369
1388
|
this.connectionLifecycle.start();
|
|
1370
1389
|
this.controlChannel.connect();
|
|
1371
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.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
|
+
}
|
|
1372
1453
|
clearMissionRetry(sessionId) {
|
|
1373
1454
|
const retry = this.missionRetryTimers.get(sessionId);
|
|
1374
1455
|
if (retry !== undefined)
|
|
@@ -1386,6 +1467,9 @@ export class NodeConnector {
|
|
|
1386
1467
|
content: this.missionRuntime.prompt(input.sessionId, 'continue'),
|
|
1387
1468
|
deliveryIntent: 'QUEUE',
|
|
1388
1469
|
mission: input.mission,
|
|
1470
|
+
...(input.initiatedByUserId === undefined
|
|
1471
|
+
? {}
|
|
1472
|
+
: { initiatedByUserId: input.initiatedByUserId }),
|
|
1389
1473
|
secretEnvironment: input.secretEnvironment,
|
|
1390
1474
|
...(input.personalInstructions === undefined
|
|
1391
1475
|
? {}
|
|
@@ -1407,8 +1491,16 @@ export class NodeConnector {
|
|
|
1407
1491
|
}
|
|
1408
1492
|
this.clearMissionRetry(input.sessionId);
|
|
1409
1493
|
}
|
|
1410
|
-
catch {
|
|
1494
|
+
catch (error) {
|
|
1411
1495
|
const current = this.missionRuntime.get(input.sessionId);
|
|
1496
|
+
const failedAttempts = (input.failedAttempts ?? 0) + 1;
|
|
1497
|
+
nodeLog('mission.continuation.enqueue.failed', {
|
|
1498
|
+
sessionId: input.sessionId,
|
|
1499
|
+
missionId: input.mission.id,
|
|
1500
|
+
revision: input.mission.revision,
|
|
1501
|
+
attempt: failedAttempts,
|
|
1502
|
+
code: safeErrorCode(error, 'MISSION_CONTINUATION_ENQUEUE_FAILED')
|
|
1503
|
+
});
|
|
1412
1504
|
if (current?.status !== 'active' ||
|
|
1413
1505
|
current.id !== input.mission.id ||
|
|
1414
1506
|
current.revision !== input.mission.revision) {
|
|
@@ -1416,7 +1508,6 @@ export class NodeConnector {
|
|
|
1416
1508
|
this.missionContinuationPending.delete(input.sessionId);
|
|
1417
1509
|
return;
|
|
1418
1510
|
}
|
|
1419
|
-
const failedAttempts = (input.failedAttempts ?? 0) + 1;
|
|
1420
1511
|
const session = this.runtime.getAgentSession(input.sessionId);
|
|
1421
1512
|
if (failedAttempts > 3 || session === undefined) {
|
|
1422
1513
|
this.clearMissionRetry(input.sessionId);
|
|
@@ -1537,6 +1628,8 @@ export class NodeConnector {
|
|
|
1537
1628
|
const terminalDrain = this.terminalManager.shutdown();
|
|
1538
1629
|
this.terminalChannel.stop();
|
|
1539
1630
|
this.runtimeCredentials.close();
|
|
1631
|
+
this.directTransferService?.dispose();
|
|
1632
|
+
this.directTransferService = undefined;
|
|
1540
1633
|
this.controlChannel.stop();
|
|
1541
1634
|
const database = this.database;
|
|
1542
1635
|
this.database = undefined;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MarAgentConversationNormalizer } from './conversation-normalizer.js';
|
|
2
|
+
import { publicMissionHistoryText } from '../../service/mission-runtime.js';
|
|
2
3
|
export function projectMarAgentHistory(session, entries, options = {}) {
|
|
3
4
|
const turns = new Map();
|
|
4
5
|
const normalizer = new MarAgentConversationNormalizer();
|
|
@@ -42,7 +43,7 @@ export function projectMarAgentHistory(session, entries, options = {}) {
|
|
|
42
43
|
const registeredImages = images === undefined ? [] : (options.registerInputImages?.(entry) ?? []);
|
|
43
44
|
replace(turn, historyItem(session, turn, id, 'user_message', 'COMPLETED', {
|
|
44
45
|
clientMessageId: entry.clientMessageId ?? null,
|
|
45
|
-
text: entry.text,
|
|
46
|
+
text: publicMissionHistoryText(entry.text),
|
|
46
47
|
contexts: [],
|
|
47
48
|
delivery: 'ACCEPTED',
|
|
48
49
|
...(images?.length
|
|
@@ -321,7 +321,7 @@ export class ContinuousRunCoordinator {
|
|
|
321
321
|
this.startedExecutions.add(executionId);
|
|
322
322
|
void this.options
|
|
323
323
|
.afterExecutionStarted?.(pending)
|
|
324
|
-
.catch(() =>
|
|
324
|
+
.catch((error) => this.options.afterExecutionStartFailed?.(pending, error));
|
|
325
325
|
}
|
|
326
326
|
hasExecutionStarted(executionId) {
|
|
327
327
|
return this.startedExecutions.has(executionId);
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { directDataControlFrameSchema, directNodeCommandSchema } from '@myagentroam/protocol';
|
|
2
|
+
import { IceUdpMuxListener as NativeIceUdpMuxListener } from 'node-datachannel';
|
|
3
|
+
import { RTCPeerConnection } from 'node-datachannel/polyfill';
|
|
4
|
+
const MAX_HOST_CANDIDATES = 32;
|
|
5
|
+
const MAX_ACTIVE_SESSIONS = 32;
|
|
6
|
+
const DATA_CHANNEL_HIGH_WATER_BYTES = 4 * 1024 * 1024;
|
|
7
|
+
const DATA_CHANNEL_LOW_WATER_BYTES = 1024 * 1024;
|
|
8
|
+
const LEASE_GRACE_MS = 30_000;
|
|
9
|
+
const LEASE_CHECK_MS = 5_000;
|
|
10
|
+
export class DirectTransferService {
|
|
11
|
+
options;
|
|
12
|
+
sessions = new Map();
|
|
13
|
+
now;
|
|
14
|
+
createPeerConnection;
|
|
15
|
+
iceUdpMuxListener;
|
|
16
|
+
peerConfiguration;
|
|
17
|
+
leaseGraceMs;
|
|
18
|
+
leaseCheckMs;
|
|
19
|
+
disposed = false;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.options = options;
|
|
22
|
+
this.now = options.now ?? Date.now;
|
|
23
|
+
const range = options.udpPortRange?.() ?? { begin: 49_152, end: 49_215 };
|
|
24
|
+
const createIceUdpMuxListener = options.createIceUdpMuxListener ?? ((port) => new NativeIceUdpMuxListener(port));
|
|
25
|
+
this.iceUdpMuxListener = bindIceUdpMuxListener(range, createIceUdpMuxListener);
|
|
26
|
+
const udpPort = this.iceUdpMuxListener.port();
|
|
27
|
+
this.peerConfiguration = {
|
|
28
|
+
iceServers: [],
|
|
29
|
+
enableIceUdpMux: true,
|
|
30
|
+
portRangeBegin: udpPort,
|
|
31
|
+
portRangeEnd: udpPort
|
|
32
|
+
};
|
|
33
|
+
this.createPeerConnection =
|
|
34
|
+
options.createPeerConnection ??
|
|
35
|
+
((configuration) => new RTCPeerConnection(configuration));
|
|
36
|
+
this.leaseGraceMs = options.leaseGraceMs ?? LEASE_GRACE_MS;
|
|
37
|
+
this.leaseCheckMs = options.leaseCheckMs ?? LEASE_CHECK_MS;
|
|
38
|
+
}
|
|
39
|
+
handle(value) {
|
|
40
|
+
const parsed = directNodeCommandSchema.safeParse(value);
|
|
41
|
+
if (!parsed.success)
|
|
42
|
+
return false;
|
|
43
|
+
void this.dispatch(parsed.data).catch((error) => {
|
|
44
|
+
const directSessionId = typeof value === 'object' &&
|
|
45
|
+
value !== null &&
|
|
46
|
+
'directSessionId' in value &&
|
|
47
|
+
typeof value.directSessionId === 'string'
|
|
48
|
+
? value.directSessionId
|
|
49
|
+
: undefined;
|
|
50
|
+
if (directSessionId !== undefined)
|
|
51
|
+
this.fail(directSessionId, errorCode(error), errorMessage(error));
|
|
52
|
+
});
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
close() {
|
|
56
|
+
for (const session of [...this.sessions.values()])
|
|
57
|
+
void this.remove(session, 'DIRECT_NODE_STOPPED');
|
|
58
|
+
}
|
|
59
|
+
dispose() {
|
|
60
|
+
if (this.disposed)
|
|
61
|
+
return;
|
|
62
|
+
this.disposed = true;
|
|
63
|
+
this.close();
|
|
64
|
+
this.iceUdpMuxListener.stop();
|
|
65
|
+
}
|
|
66
|
+
async dispatch(command) {
|
|
67
|
+
if (command.type === 'direct.prepare') {
|
|
68
|
+
this.prepare(command);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const session = this.sessions.get(command.directSessionId);
|
|
72
|
+
if (session === undefined)
|
|
73
|
+
throw new Error('DIRECT_SESSION_INVALID');
|
|
74
|
+
if (command.type === 'direct.signal') {
|
|
75
|
+
await this.signal(session, command.signal);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (command.type === 'direct.grant') {
|
|
79
|
+
if (command.browserFingerprint !== fingerprint(session.peer.remoteDescription?.sdp) ||
|
|
80
|
+
command.nodeFingerprint !== fingerprint(session.peer.localDescription?.sdp) ||
|
|
81
|
+
command.openExpiresAt <= this.now() ||
|
|
82
|
+
command.expiresAt > session.command.expiresAt)
|
|
83
|
+
throw new Error('DIRECT_GRANT_INVALID');
|
|
84
|
+
session.grant = command;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (command.type === 'direct.heartbeat') {
|
|
88
|
+
if (session.command.expiresAt <= this.now())
|
|
89
|
+
await this.remove(session, 'DIRECT_SESSION_EXPIRED');
|
|
90
|
+
else
|
|
91
|
+
session.lastLeaseAliveAt = this.now();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
await this.remove(session, command.code);
|
|
95
|
+
}
|
|
96
|
+
prepare(command) {
|
|
97
|
+
if (command.nodeGeneration !== this.options.nodeGeneration())
|
|
98
|
+
throw new Error('DIRECT_NODE_GENERATION_MISMATCH');
|
|
99
|
+
if (this.sessions.has(command.directSessionId))
|
|
100
|
+
throw new Error('DIRECT_SESSION_CONFLICT');
|
|
101
|
+
if (this.sessions.size >= MAX_ACTIVE_SESSIONS)
|
|
102
|
+
throw new Error('DIRECT_SESSION_LIMIT_REACHED');
|
|
103
|
+
const peer = this.createPeerConnection(this.peerConfiguration);
|
|
104
|
+
const session = {
|
|
105
|
+
command,
|
|
106
|
+
peer,
|
|
107
|
+
opened: false,
|
|
108
|
+
uploadCommitted: false,
|
|
109
|
+
processing: Promise.resolve(),
|
|
110
|
+
sentCandidateKeys: new Set()
|
|
111
|
+
};
|
|
112
|
+
this.sessions.set(command.directSessionId, session);
|
|
113
|
+
peer.onicecandidate = (event) => {
|
|
114
|
+
const candidate = event.candidate;
|
|
115
|
+
if (candidate === null) {
|
|
116
|
+
this.emit(command.directSessionId, {
|
|
117
|
+
type: 'direct.signal',
|
|
118
|
+
directSessionId: command.directSessionId,
|
|
119
|
+
signal: { kind: 'END_OF_CANDIDATES' }
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const json = candidate.toJSON();
|
|
124
|
+
if (typeof json.candidate !== 'string' ||
|
|
125
|
+
!acceptHostCandidate(json.candidate, session.sentCandidateKeys))
|
|
126
|
+
return;
|
|
127
|
+
this.emit(command.directSessionId, {
|
|
128
|
+
type: 'direct.signal',
|
|
129
|
+
directSessionId: command.directSessionId,
|
|
130
|
+
signal: {
|
|
131
|
+
kind: 'CANDIDATE',
|
|
132
|
+
candidate: {
|
|
133
|
+
candidate: json.candidate,
|
|
134
|
+
sdpMid: json.sdpMid ?? null,
|
|
135
|
+
sdpMLineIndex: json.sdpMLineIndex ?? null
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
peer.ondatachannel = (event) => this.attachChannel(session, event.channel);
|
|
141
|
+
peer.onconnectionstatechange = () => {
|
|
142
|
+
if (peer.connectionState === 'connected')
|
|
143
|
+
this.emit(session.command.directSessionId, {
|
|
144
|
+
type: 'direct.connected',
|
|
145
|
+
directSessionId: session.command.directSessionId
|
|
146
|
+
});
|
|
147
|
+
if (peer.connectionState === 'failed' || peer.connectionState === 'closed')
|
|
148
|
+
void this.remove(session, 'DIRECT_CONNECTION_FAILED');
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
async signal(session, signal) {
|
|
152
|
+
if (signal.kind === 'CANDIDATE') {
|
|
153
|
+
if (!safeHostCandidate(signal.candidate.candidate))
|
|
154
|
+
throw new Error('DIRECT_CANDIDATE_REJECTED');
|
|
155
|
+
await session.peer.addIceCandidate(signal.candidate);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (signal.kind === 'END_OF_CANDIDATES') {
|
|
159
|
+
await session.peer.addIceCandidate(null);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (!hostOnlySdp(signal.sdp))
|
|
163
|
+
throw new Error('DIRECT_CANDIDATE_REJECTED');
|
|
164
|
+
await session.peer.setRemoteDescription({
|
|
165
|
+
type: signal.kind === 'OFFER' ? 'offer' : 'answer',
|
|
166
|
+
sdp: signal.sdp
|
|
167
|
+
});
|
|
168
|
+
if (signal.kind !== 'OFFER')
|
|
169
|
+
return;
|
|
170
|
+
const answer = await session.peer.createAnswer();
|
|
171
|
+
await session.peer.setLocalDescription(answer);
|
|
172
|
+
const localSdp = session.peer.localDescription?.sdp;
|
|
173
|
+
const filtered = filterIceCandidates(localSdp, session.sentCandidateKeys);
|
|
174
|
+
const sdp = filtered.sdp;
|
|
175
|
+
const localFingerprint = fingerprint(localSdp);
|
|
176
|
+
if (sdp === undefined || localFingerprint === undefined)
|
|
177
|
+
throw new Error('DIRECT_FINGERPRINT_MISSING');
|
|
178
|
+
this.emit(session.command.directSessionId, {
|
|
179
|
+
type: 'direct.signal',
|
|
180
|
+
directSessionId: session.command.directSessionId,
|
|
181
|
+
signal: { kind: 'ANSWER', sdp, fingerprint: localFingerprint }
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
attachChannel(session, channel) {
|
|
185
|
+
session.channel = channel;
|
|
186
|
+
channel.binaryType = 'arraybuffer';
|
|
187
|
+
channel.onmessage = (event) => {
|
|
188
|
+
session.processing = session.processing
|
|
189
|
+
.then(() => this.channelMessage(session, event.data))
|
|
190
|
+
.catch((error) => this.fail(session.command.directSessionId, errorCode(error), errorMessage(error)));
|
|
191
|
+
};
|
|
192
|
+
channel.onclose = () => {
|
|
193
|
+
if (this.sessions.get(session.command.directSessionId) === session)
|
|
194
|
+
void this.remove(session, 'DIRECT_CHANNEL_CLOSED');
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
async channelMessage(session, data) {
|
|
198
|
+
if (!session.opened) {
|
|
199
|
+
if (typeof data !== 'string')
|
|
200
|
+
throw new Error('DIRECT_OPEN_REQUIRED');
|
|
201
|
+
const parsed = directDataControlFrameSchema.parse(JSON.parse(data));
|
|
202
|
+
if (parsed.type !== 'OPEN')
|
|
203
|
+
throw new Error('DIRECT_OPEN_REQUIRED');
|
|
204
|
+
const grant = session.grant;
|
|
205
|
+
if (grant === undefined ||
|
|
206
|
+
parsed.directSessionId !== session.command.directSessionId ||
|
|
207
|
+
parsed.grant !== grant.grant ||
|
|
208
|
+
grant.openExpiresAt <= this.now() ||
|
|
209
|
+
grant.expiresAt <= this.now())
|
|
210
|
+
throw new Error('DIRECT_GRANT_INVALID');
|
|
211
|
+
session.opened = true;
|
|
212
|
+
if (session.command.purpose === 'TRANSFER')
|
|
213
|
+
this.startLeaseWatch(session);
|
|
214
|
+
await this.open(session);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (session.command.purpose !== 'TRANSFER' || session.command.transfer === undefined)
|
|
218
|
+
throw new Error('DIRECT_FRAME_INVALID');
|
|
219
|
+
if (session.command.transfer.direction === 'DOWNLOAD') {
|
|
220
|
+
if (typeof data !== 'string')
|
|
221
|
+
throw new Error('DIRECT_FRAME_INVALID');
|
|
222
|
+
const control = directDataControlFrameSchema.parse(JSON.parse(data));
|
|
223
|
+
if (control.type !== 'ACK' || control.offset !== session.command.transfer.size)
|
|
224
|
+
throw new Error('DIRECT_FRAME_INVALID');
|
|
225
|
+
this.emit(session.command.directSessionId, {
|
|
226
|
+
type: 'direct.complete',
|
|
227
|
+
directSessionId: session.command.directSessionId,
|
|
228
|
+
size: session.command.transfer.size,
|
|
229
|
+
sha256: session.command.transfer.sha256
|
|
230
|
+
});
|
|
231
|
+
await this.remove(session);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (typeof data === 'string') {
|
|
235
|
+
const control = directDataControlFrameSchema.parse(JSON.parse(data));
|
|
236
|
+
if (control.type !== 'COMPLETE' || session.upload === undefined)
|
|
237
|
+
throw new Error('DIRECT_FRAME_INVALID');
|
|
238
|
+
if (control.size !== session.command.transfer.size ||
|
|
239
|
+
control.sha256 !== session.command.transfer.sha256)
|
|
240
|
+
throw new Error('DIRECT_INTEGRITY_FAILED');
|
|
241
|
+
await session.upload.complete(control.size, control.sha256);
|
|
242
|
+
session.uploadCommitted = true;
|
|
243
|
+
this.emit(session.command.directSessionId, {
|
|
244
|
+
type: 'direct.complete',
|
|
245
|
+
directSessionId: session.command.directSessionId,
|
|
246
|
+
size: control.size,
|
|
247
|
+
sha256: control.sha256
|
|
248
|
+
});
|
|
249
|
+
await this.remove(session);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const bytes = toBytes(data);
|
|
253
|
+
if (bytes.byteLength === 0 || bytes.byteLength > 256 * 1024 || session.upload === undefined)
|
|
254
|
+
throw new Error('DIRECT_CHUNK_INVALID');
|
|
255
|
+
const offset = await session.upload.write(bytes);
|
|
256
|
+
session.channel?.send(JSON.stringify({ type: 'ACK', offset }));
|
|
257
|
+
}
|
|
258
|
+
async open(session) {
|
|
259
|
+
if (session.command.purpose === 'PROBE') {
|
|
260
|
+
session.channel?.send(JSON.stringify({ type: 'ACK', offset: 0 }));
|
|
261
|
+
this.emit(session.command.directSessionId, {
|
|
262
|
+
type: 'direct.probe-result',
|
|
263
|
+
directSessionId: session.command.directSessionId,
|
|
264
|
+
result: 'DIRECT_AVAILABLE'
|
|
265
|
+
});
|
|
266
|
+
await this.remove(session);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const descriptor = session.command.transfer;
|
|
270
|
+
if (descriptor === undefined)
|
|
271
|
+
throw new Error('DIRECT_TRANSFER_INVALID');
|
|
272
|
+
if (descriptor.direction === 'UPLOAD') {
|
|
273
|
+
if (this.options.openUpload === undefined)
|
|
274
|
+
throw new Error('DIRECT_UPLOAD_UNAVAILABLE');
|
|
275
|
+
session.upload = await this.options.openUpload(descriptor);
|
|
276
|
+
session.channel?.send(JSON.stringify({ type: 'ACK', offset: session.upload.offset }));
|
|
277
|
+
this.emit(session.command.directSessionId, {
|
|
278
|
+
type: 'direct.opened',
|
|
279
|
+
directSessionId: session.command.directSessionId,
|
|
280
|
+
offset: session.upload.offset
|
|
281
|
+
});
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (this.options.openDownload === undefined)
|
|
285
|
+
throw new Error('DIRECT_DOWNLOAD_UNAVAILABLE');
|
|
286
|
+
const source = await this.options.openDownload(descriptor);
|
|
287
|
+
session.channel?.send(JSON.stringify({ type: 'ACK', offset: source.offset }));
|
|
288
|
+
this.emit(session.command.directSessionId, {
|
|
289
|
+
type: 'direct.opened',
|
|
290
|
+
directSessionId: session.command.directSessionId,
|
|
291
|
+
offset: source.offset
|
|
292
|
+
});
|
|
293
|
+
for await (const chunk of source.chunks) {
|
|
294
|
+
for (let offset = 0; offset < chunk.byteLength; offset += 256 * 1024) {
|
|
295
|
+
if (this.sessions.get(session.command.directSessionId) !== session)
|
|
296
|
+
return;
|
|
297
|
+
const channel = session.channel;
|
|
298
|
+
if (channel === undefined)
|
|
299
|
+
throw new Error('DIRECT_CHANNEL_CLOSED');
|
|
300
|
+
await this.waitForSendCapacity(session, channel);
|
|
301
|
+
const end = Math.min(chunk.byteLength, offset + 256 * 1024);
|
|
302
|
+
channel.send(chunk.buffer.slice(chunk.byteOffset + offset, chunk.byteOffset + end));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
session.channel?.send(JSON.stringify({
|
|
306
|
+
type: 'COMPLETE',
|
|
307
|
+
size: descriptor.size,
|
|
308
|
+
sha256: descriptor.sha256
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
async waitForSendCapacity(session, channel) {
|
|
312
|
+
if (channel.readyState !== 'open')
|
|
313
|
+
throw new Error('DIRECT_CHANNEL_CLOSED');
|
|
314
|
+
if (channel.bufferedAmount <= DATA_CHANNEL_HIGH_WATER_BYTES)
|
|
315
|
+
return;
|
|
316
|
+
channel.bufferedAmountLowThreshold = DATA_CHANNEL_LOW_WATER_BYTES;
|
|
317
|
+
await new Promise((resolve, reject) => {
|
|
318
|
+
const complete = (error) => {
|
|
319
|
+
channel.removeEventListener('bufferedamountlow', available);
|
|
320
|
+
channel.removeEventListener('close', closed);
|
|
321
|
+
if (error === undefined)
|
|
322
|
+
resolve();
|
|
323
|
+
else
|
|
324
|
+
reject(error);
|
|
325
|
+
};
|
|
326
|
+
const available = () => complete();
|
|
327
|
+
const closed = () => complete(new Error('DIRECT_CHANNEL_CLOSED'));
|
|
328
|
+
channel.addEventListener('bufferedamountlow', available, { once: true });
|
|
329
|
+
channel.addEventListener('close', closed, { once: true });
|
|
330
|
+
if (this.sessions.get(session.command.directSessionId) !== session ||
|
|
331
|
+
channel.readyState !== 'open')
|
|
332
|
+
closed();
|
|
333
|
+
else if (channel.bufferedAmount <= DATA_CHANNEL_LOW_WATER_BYTES)
|
|
334
|
+
available();
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
startLeaseWatch(session) {
|
|
338
|
+
session.lastLeaseAliveAt = this.now();
|
|
339
|
+
session.leaseTimer = setInterval(() => {
|
|
340
|
+
const now = this.now();
|
|
341
|
+
if (session.command.expiresAt <= now) {
|
|
342
|
+
void this.remove(session, 'DIRECT_SESSION_EXPIRED');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (now - (session.lastLeaseAliveAt ?? now) > this.leaseGraceMs)
|
|
346
|
+
void this.remove(session, 'DIRECT_LEASE_EXPIRED');
|
|
347
|
+
}, this.leaseCheckMs);
|
|
348
|
+
}
|
|
349
|
+
emit(directSessionId, event) {
|
|
350
|
+
if (this.sessions.has(directSessionId))
|
|
351
|
+
this.options.send(event.type, event);
|
|
352
|
+
}
|
|
353
|
+
fail(directSessionId, code, message) {
|
|
354
|
+
const session = this.sessions.get(directSessionId);
|
|
355
|
+
this.options.send('direct.error', { type: 'direct.error', directSessionId, code, message });
|
|
356
|
+
if (session !== undefined)
|
|
357
|
+
void this.remove(session);
|
|
358
|
+
}
|
|
359
|
+
async remove(session, code) {
|
|
360
|
+
if (!this.sessions.delete(session.command.directSessionId))
|
|
361
|
+
return;
|
|
362
|
+
if (session.leaseTimer !== undefined)
|
|
363
|
+
clearInterval(session.leaseTimer);
|
|
364
|
+
if (!session.uploadCommitted)
|
|
365
|
+
await session.upload?.cancel().catch(() => undefined);
|
|
366
|
+
session.channel?.close();
|
|
367
|
+
session.peer.close();
|
|
368
|
+
if (code !== undefined)
|
|
369
|
+
this.options.send('direct.error', {
|
|
370
|
+
type: 'direct.error',
|
|
371
|
+
directSessionId: session.command.directSessionId,
|
|
372
|
+
code,
|
|
373
|
+
message: directErrorMessage(code)
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function bindIceUdpMuxListener(range, create) {
|
|
378
|
+
let lastError;
|
|
379
|
+
for (let port = range.begin; port <= range.end; port += 1) {
|
|
380
|
+
try {
|
|
381
|
+
return create(port);
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
lastError = error;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
throw new Error('DIRECT_UDP_PORT_UNAVAILABLE', { cause: lastError });
|
|
388
|
+
}
|
|
389
|
+
export function fingerprint(sdp) {
|
|
390
|
+
const match = sdp?.match(/^a=fingerprint:(sha-256 [A-F0-9:]+)\r?$/imu);
|
|
391
|
+
return match?.[1];
|
|
392
|
+
}
|
|
393
|
+
function safeHostCandidate(candidate) {
|
|
394
|
+
if (!/\styp host(?:\s|$)/u.test(candidate))
|
|
395
|
+
return false;
|
|
396
|
+
const address = candidate.trim().split(/\s+/u)[4];
|
|
397
|
+
if (address === undefined)
|
|
398
|
+
return false;
|
|
399
|
+
return address.length > 0;
|
|
400
|
+
}
|
|
401
|
+
function hostOnlySdp(sdp) {
|
|
402
|
+
return sdp
|
|
403
|
+
.split(/\r?\n/u)
|
|
404
|
+
.filter((line) => line.startsWith('a=candidate:'))
|
|
405
|
+
.every((line) => safeHostCandidate(line.slice(2)));
|
|
406
|
+
}
|
|
407
|
+
function candidateKey(candidate) {
|
|
408
|
+
const fields = candidate.trim().split(/\s+/u);
|
|
409
|
+
const component = fields[1];
|
|
410
|
+
const protocol = fields[2]?.toLowerCase();
|
|
411
|
+
const address = fields[4]?.toLowerCase();
|
|
412
|
+
if (component === undefined || protocol === undefined || address === undefined)
|
|
413
|
+
return undefined;
|
|
414
|
+
return `${component}:${protocol}:${address}`;
|
|
415
|
+
}
|
|
416
|
+
function acceptHostCandidate(candidate, accepted) {
|
|
417
|
+
if (!safeHostCandidate(candidate))
|
|
418
|
+
return false;
|
|
419
|
+
const key = candidateKey(candidate);
|
|
420
|
+
if (key === undefined || accepted.has(key) || accepted.size >= MAX_HOST_CANDIDATES)
|
|
421
|
+
return false;
|
|
422
|
+
accepted.add(key);
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
function filterIceCandidates(sdp, accepted) {
|
|
426
|
+
if (sdp === undefined)
|
|
427
|
+
return { sdp: undefined };
|
|
428
|
+
const included = new Set();
|
|
429
|
+
const lines = sdp.split(/\r?\n/u).filter((line) => {
|
|
430
|
+
if (!line.startsWith('a=candidate:'))
|
|
431
|
+
return true;
|
|
432
|
+
const candidate = line.slice(2);
|
|
433
|
+
if (!safeHostCandidate(candidate))
|
|
434
|
+
return false;
|
|
435
|
+
const key = candidateKey(candidate);
|
|
436
|
+
if (key === undefined || included.has(key))
|
|
437
|
+
return false;
|
|
438
|
+
if (!accepted.has(key) && accepted.size >= MAX_HOST_CANDIDATES)
|
|
439
|
+
return false;
|
|
440
|
+
accepted.add(key);
|
|
441
|
+
included.add(key);
|
|
442
|
+
return true;
|
|
443
|
+
});
|
|
444
|
+
return { sdp: lines.join('\r\n') };
|
|
445
|
+
}
|
|
446
|
+
function toBytes(value) {
|
|
447
|
+
if (value instanceof ArrayBuffer)
|
|
448
|
+
return new Uint8Array(value);
|
|
449
|
+
if (ArrayBuffer.isView(value))
|
|
450
|
+
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
451
|
+
throw new Error('DIRECT_CHUNK_INVALID');
|
|
452
|
+
}
|
|
453
|
+
function errorCode(error) {
|
|
454
|
+
return error instanceof Error && /^[A-Z][A-Z0-9_]{0,127}$/u.test(error.message)
|
|
455
|
+
? error.message
|
|
456
|
+
: 'DIRECT_INTERNAL_ERROR';
|
|
457
|
+
}
|
|
458
|
+
function errorMessage(error) {
|
|
459
|
+
const code = errorCode(error);
|
|
460
|
+
return code === 'DIRECT_INTERNAL_ERROR'
|
|
461
|
+
? 'The Node could not complete the direct transfer operation.'
|
|
462
|
+
: directErrorMessage(code);
|
|
463
|
+
}
|
|
464
|
+
function directErrorMessage(code) {
|
|
465
|
+
const messages = {
|
|
466
|
+
DIRECT_NODE_GENERATION_MISMATCH: 'The Node connection generation changed.',
|
|
467
|
+
DIRECT_SESSION_INVALID: 'The direct transfer session is unavailable.',
|
|
468
|
+
DIRECT_SESSION_CONFLICT: 'The direct transfer session already exists.',
|
|
469
|
+
DIRECT_SESSION_LIMIT_REACHED: 'The direct transfer session limit has been reached.',
|
|
470
|
+
DIRECT_CANDIDATE_REJECTED: 'The direct transfer candidate is not a host candidate.',
|
|
471
|
+
DIRECT_GRANT_INVALID: 'The direct transfer grant is invalid or expired.',
|
|
472
|
+
DIRECT_CONNECTION_FAILED: 'The direct connection failed.',
|
|
473
|
+
DIRECT_CHANNEL_CLOSED: 'The direct data channel closed.',
|
|
474
|
+
DIRECT_SESSION_EXPIRED: 'The direct transfer session expired.',
|
|
475
|
+
DIRECT_LEASE_EXPIRED: 'The Server authorization lease expired.',
|
|
476
|
+
DIRECT_NODE_STOPPED: 'The Node stopped the direct transfer service.'
|
|
477
|
+
};
|
|
478
|
+
return messages[code] ?? 'The direct transfer operation failed.';
|
|
479
|
+
}
|
|
@@ -14,6 +14,16 @@ export function stripMissionStartPrompt(text) {
|
|
|
14
14
|
const close = text.indexOf(START_CLOSE);
|
|
15
15
|
return close === -1 ? '' : text.slice(close + START_CLOSE.length).trimStart();
|
|
16
16
|
}
|
|
17
|
+
export function publicMissionHistoryText(text) {
|
|
18
|
+
if (!text.startsWith(START_OPEN))
|
|
19
|
+
return text;
|
|
20
|
+
const stripped = stripMissionStartPrompt(text);
|
|
21
|
+
if (stripped.length > 0)
|
|
22
|
+
return stripped;
|
|
23
|
+
const close = text.indexOf(START_CLOSE);
|
|
24
|
+
const prompt = close === -1 ? text : text.slice(0, close);
|
|
25
|
+
return prompt.includes('Continue the active Mission.') ? 'Mission Continue' : 'Mission Start';
|
|
26
|
+
}
|
|
17
27
|
export class MissionRuntime {
|
|
18
28
|
changed;
|
|
19
29
|
missions = new Map();
|
|
@@ -37,6 +37,7 @@ export class NodeConnectionLifecycleService {
|
|
|
37
37
|
...(config.conversationHosting === undefined
|
|
38
38
|
? {}
|
|
39
39
|
: { conversationHosting: config.conversationHosting }),
|
|
40
|
+
...(config.directTransfer === undefined ? {} : { directTransfer: config.directTransfer }),
|
|
40
41
|
nodeId,
|
|
41
42
|
credential
|
|
42
43
|
};
|
|
@@ -35,6 +35,9 @@ export class NodeControlMessageService {
|
|
|
35
35
|
}
|
|
36
36
|
if (this.options.handleRuntimeCredentialResponse(envelope))
|
|
37
37
|
return;
|
|
38
|
+
if (envelope.type.startsWith('direct.') &&
|
|
39
|
+
this.options.handleDirectTransfer?.(envelope) === true)
|
|
40
|
+
return;
|
|
38
41
|
if (envelope.type === 'ack')
|
|
39
42
|
return;
|
|
40
43
|
if (envelope.type === 'runs.reconcile') {
|
|
@@ -25,6 +25,9 @@ export class SessionMessageService {
|
|
|
25
25
|
content: input.kind === 'start' ? 'Mission Start' : 'Mission Continue',
|
|
26
26
|
__runnerContent: input.content,
|
|
27
27
|
__missionRef: input.mission,
|
|
28
|
+
...(input.initiatedByUserId === undefined
|
|
29
|
+
? {}
|
|
30
|
+
: { __requestUserId: input.initiatedByUserId }),
|
|
28
31
|
deliveryIntent: input.deliveryIntent,
|
|
29
32
|
secretEnvironment: input.secretEnvironment,
|
|
30
33
|
personalInstructions: input.personalInstructions,
|
|
@@ -23,6 +23,7 @@ export class WorkbenchManifestService {
|
|
|
23
23
|
manifestVersion: 1,
|
|
24
24
|
revision: revisionFor(capabilities),
|
|
25
25
|
observedAt: Date.now(),
|
|
26
|
+
nodeGeneration: this.options.nodeGeneration(),
|
|
26
27
|
platform: capabilities.platform,
|
|
27
28
|
architecture: capabilities.architecture,
|
|
28
29
|
conversation: {
|
|
@@ -45,6 +46,7 @@ export class WorkbenchManifestService {
|
|
|
45
46
|
maxReadBytes: 512 * 1024,
|
|
46
47
|
maxSearchResults: 200
|
|
47
48
|
},
|
|
49
|
+
directTransfer: capabilities.workspaceDirectTransfer,
|
|
48
50
|
git: {
|
|
49
51
|
available: true,
|
|
50
52
|
reasonCode: null,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { tmpdir } from 'node:os';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { isAbsolute, win32 } from 'node:path';
|
|
3
4
|
import { listWorkspaceFiles, mutateWorkspaceFile, normalizeFilePreviewPath, readAllowedFileContentRange, readWorkspaceFileContentRange, readAllowedTextFile, readWorkspaceTextFile, writeWorkspaceTextFile, WorkspaceFileIndex } from '../workspace.js';
|
|
4
5
|
const INDEX_CACHE_LIMIT = 8;
|
|
@@ -58,6 +59,54 @@ export class WorkspaceFileService {
|
|
|
58
59
|
? readAllowedFileContentRange(path, this.previewRoots(), offset, limit)
|
|
59
60
|
: readWorkspaceFileContentRange(workspacePath, path, offset, limit);
|
|
60
61
|
}
|
|
62
|
+
readDirect(workspace, input, workspacePath = workspace.path) {
|
|
63
|
+
const service = this;
|
|
64
|
+
return (async function* () {
|
|
65
|
+
const hash = createHash('sha256');
|
|
66
|
+
let offset = 0;
|
|
67
|
+
while (true) {
|
|
68
|
+
const range = (await service.readContent(workspace, {
|
|
69
|
+
path: input.path,
|
|
70
|
+
offset,
|
|
71
|
+
limit: 512 * 1024
|
|
72
|
+
}, workspacePath));
|
|
73
|
+
if (range.size !== input.size)
|
|
74
|
+
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
75
|
+
const bytes = Buffer.from(range.contentBase64, 'base64');
|
|
76
|
+
hash.update(bytes);
|
|
77
|
+
offset += bytes.length;
|
|
78
|
+
if (bytes.length > 0)
|
|
79
|
+
yield bytes;
|
|
80
|
+
if (range.nextOffset === null)
|
|
81
|
+
break;
|
|
82
|
+
if (range.nextOffset !== offset)
|
|
83
|
+
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
84
|
+
}
|
|
85
|
+
if (offset !== input.size || hash.digest('hex') !== input.sha256)
|
|
86
|
+
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
87
|
+
})();
|
|
88
|
+
}
|
|
89
|
+
async directMetadata(workspace, input, workspacePath = workspace.path) {
|
|
90
|
+
const hash = createHash('sha256');
|
|
91
|
+
let offset = 0;
|
|
92
|
+
let size;
|
|
93
|
+
while (true) {
|
|
94
|
+
const range = (await this.readContent(workspace, { ...input, offset, limit: 512 * 1024 }, workspacePath));
|
|
95
|
+
size ??= range.size;
|
|
96
|
+
if (range.size !== size)
|
|
97
|
+
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
98
|
+
const bytes = Buffer.from(range.contentBase64, 'base64');
|
|
99
|
+
hash.update(bytes);
|
|
100
|
+
offset += bytes.length;
|
|
101
|
+
if (range.nextOffset === null)
|
|
102
|
+
break;
|
|
103
|
+
if (range.nextOffset !== offset)
|
|
104
|
+
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
105
|
+
}
|
|
106
|
+
if (size === undefined || offset !== size)
|
|
107
|
+
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
108
|
+
return { size, sha256: hash.digest('hex') };
|
|
109
|
+
}
|
|
61
110
|
async write(workspace, input) {
|
|
62
111
|
if (typeof input.path !== 'string' ||
|
|
63
112
|
typeof input.content !== 'string' ||
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
2
3
|
import { lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
|
|
3
|
-
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { dirname, join, posix, resolve } from 'node:path';
|
|
4
5
|
import { parseComposerAttachmentUpload, validComposerAttachmentName } from '../util/node-operation-parsers.js';
|
|
5
6
|
import { preflightWorkspaceUpload, workspaceUploadTemporaryName } from '../workspace.js';
|
|
6
7
|
const CHUNK_BYTES = 512 * 1024;
|
|
@@ -9,6 +10,7 @@ const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
|
|
9
10
|
export class WorkspaceUploadService {
|
|
10
11
|
state;
|
|
11
12
|
onCompleted;
|
|
13
|
+
directHashes = new Map();
|
|
12
14
|
constructor(state, onCompleted) {
|
|
13
15
|
this.state = state;
|
|
14
16
|
this.onCompleted = onCompleted;
|
|
@@ -99,6 +101,44 @@ export class WorkspaceUploadService {
|
|
|
99
101
|
upload.receivedBytes += bytes.length;
|
|
100
102
|
return this.status(upload.id);
|
|
101
103
|
}
|
|
104
|
+
async createDirect(workspace, input) {
|
|
105
|
+
const normalized = input.path.replaceAll('\\', '/');
|
|
106
|
+
const status = await this.create(workspace, {
|
|
107
|
+
name: posix.basename(normalized),
|
|
108
|
+
size: input.size,
|
|
109
|
+
overwrite: input.overwrite,
|
|
110
|
+
...(input.composerAttachment === undefined
|
|
111
|
+
? { directory: posix.dirname(normalized) }
|
|
112
|
+
: {
|
|
113
|
+
composerAttachmentId: input.composerAttachment.id,
|
|
114
|
+
mime: input.composerAttachment.mime
|
|
115
|
+
})
|
|
116
|
+
});
|
|
117
|
+
this.directHashes.set(status.uploadId, input.sha256);
|
|
118
|
+
return status;
|
|
119
|
+
}
|
|
120
|
+
async writeDirect(uploadId, bytes) {
|
|
121
|
+
const status = await this.write({
|
|
122
|
+
uploadId,
|
|
123
|
+
offset: this.status(uploadId).receivedBytes,
|
|
124
|
+
dataBase64: Buffer.from(bytes).toString('base64')
|
|
125
|
+
});
|
|
126
|
+
return status.receivedBytes;
|
|
127
|
+
}
|
|
128
|
+
async completeDirect(uploadId, size, sha256) {
|
|
129
|
+
const upload = this.require(uploadId);
|
|
130
|
+
const expected = this.directHashes.get(uploadId);
|
|
131
|
+
if (size !== upload.size || sha256 !== expected)
|
|
132
|
+
throw new Error('UPLOAD_HASH_INVALID');
|
|
133
|
+
await upload.handle.sync();
|
|
134
|
+
const actual = await fileSha256(upload.temporaryPath);
|
|
135
|
+
if (actual !== expected) {
|
|
136
|
+
await this.cancel(uploadId);
|
|
137
|
+
throw new Error('UPLOAD_HASH_INVALID');
|
|
138
|
+
}
|
|
139
|
+
await this.complete(uploadId);
|
|
140
|
+
this.directHashes.delete(uploadId);
|
|
141
|
+
}
|
|
102
142
|
status(uploadId) {
|
|
103
143
|
const upload = this.require(uploadId);
|
|
104
144
|
return {
|
|
@@ -163,8 +203,10 @@ export class WorkspaceUploadService {
|
|
|
163
203
|
if (typeof uploadId !== 'string')
|
|
164
204
|
throw new Error('UPLOAD_NOT_FOUND');
|
|
165
205
|
const active = this.state.uploads.get(uploadId);
|
|
166
|
-
if (active !== undefined)
|
|
206
|
+
if (active !== undefined) {
|
|
207
|
+
this.directHashes.delete(active.id);
|
|
167
208
|
return this.remove(active);
|
|
209
|
+
}
|
|
168
210
|
const attachment = this.state.composerAttachments.get(uploadId);
|
|
169
211
|
if (attachment === undefined)
|
|
170
212
|
throw new Error('UPLOAD_NOT_FOUND');
|
|
@@ -204,6 +246,7 @@ export class WorkspaceUploadService {
|
|
|
204
246
|
for (const attachment of this.state.composerAttachments.values())
|
|
205
247
|
await this.removeCompleted(attachment);
|
|
206
248
|
this.state.composerAttachments.clear();
|
|
249
|
+
this.directHashes.clear();
|
|
207
250
|
}
|
|
208
251
|
require(id) {
|
|
209
252
|
if (typeof id !== 'string')
|
|
@@ -215,6 +258,7 @@ export class WorkspaceUploadService {
|
|
|
215
258
|
}
|
|
216
259
|
async remove(upload) {
|
|
217
260
|
this.state.uploads.delete(upload.id);
|
|
261
|
+
this.directHashes.delete(upload.id);
|
|
218
262
|
clearTimeout(upload.timer);
|
|
219
263
|
await upload.handle.close().catch(() => undefined);
|
|
220
264
|
await rm(upload.temporaryPath, { force: true }).catch(() => undefined);
|
|
@@ -252,6 +296,12 @@ export class WorkspaceUploadService {
|
|
|
252
296
|
};
|
|
253
297
|
}
|
|
254
298
|
}
|
|
299
|
+
async function fileSha256(path) {
|
|
300
|
+
const hash = createHash('sha256');
|
|
301
|
+
for await (const chunk of createReadStream(path))
|
|
302
|
+
hash.update(chunk);
|
|
303
|
+
return hash.digest('hex');
|
|
304
|
+
}
|
|
255
305
|
async function safeDirectory(path) {
|
|
256
306
|
const value = await lstat(path);
|
|
257
307
|
if (!value.isDirectory() || value.isSymbolicLink())
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { listGitHistoryRefs, openGitHistoryFileContent, readGitHistory, readGitHistoryFileDiff, readGitHistoryFileContentRange, readGitHistoryFiles, readCurrentChangeDiff, readRestrictedDiff, resolveGitRepository, resolveGitRepositoryTarget, restoreCurrentChange } from '../workspace.js';
|
|
2
3
|
import { WorkspaceContentSearchService } from './workspace-content-search-service.js';
|
|
3
4
|
import { WorkspaceWatchController } from './workspace-watch-controller.js';
|
|
4
5
|
import { WorkspaceWatchSnapshotService } from './workspace-watch-snapshot-service.js';
|
|
@@ -25,6 +26,7 @@ export class WorkspaceWorkbenchService {
|
|
|
25
26
|
'workspace.file.read': (data) => this.readFile(record(data)),
|
|
26
27
|
'workspace.file.write': (data) => this.writeFile(record(data)),
|
|
27
28
|
'workspace.file.content.read': (data) => this.readFileContent(record(data)),
|
|
29
|
+
'workspace.file.direct.metadata': (data) => this.directFileMetadata(record(data)),
|
|
28
30
|
'workspace.files.search': (data) => this.searchFiles(record(data)),
|
|
29
31
|
'workspace.files.mutate': (data) => this.mutateFile(record(data)),
|
|
30
32
|
'workspace.content.search': (data) => this.searchContent(record(data)),
|
|
@@ -44,6 +46,7 @@ export class WorkspaceWorkbenchService {
|
|
|
44
46
|
'workspace.git.commit.files': (data) => this.gitCommitFiles(record(data)),
|
|
45
47
|
'workspace.git.commit.file.diff': (data) => this.gitCommitFileDiff(record(data)),
|
|
46
48
|
'workspace.git.commit.file.content.read': (data) => this.gitCommitFileContent(record(data)),
|
|
49
|
+
'workspace.git.commit.file.direct.metadata': (data) => this.gitCommitFileDirectMetadata(record(data)),
|
|
47
50
|
'workspace.diff': (data) => this.diff(record(data))
|
|
48
51
|
};
|
|
49
52
|
}
|
|
@@ -123,6 +126,15 @@ export class WorkspaceWorkbenchService {
|
|
|
123
126
|
file: await this.options.files.readContent(workspace, input, repository)
|
|
124
127
|
};
|
|
125
128
|
}
|
|
129
|
+
async directFileMetadata(input) {
|
|
130
|
+
if (typeof input.workspaceId !== 'string')
|
|
131
|
+
throw new Error('WORKSPACE_NOT_FOUND');
|
|
132
|
+
const workspace = this.options.requireWorkspace(input.workspaceId);
|
|
133
|
+
const repository = await this.fileRepository(workspace, input);
|
|
134
|
+
return {
|
|
135
|
+
file: await this.options.files.directMetadata(workspace, input, repository)
|
|
136
|
+
};
|
|
137
|
+
}
|
|
126
138
|
async changeDiff(input) {
|
|
127
139
|
if (typeof input.workspaceId !== 'string' ||
|
|
128
140
|
typeof input.path !== 'string' ||
|
|
@@ -217,6 +229,76 @@ export class WorkspaceWorkbenchService {
|
|
|
217
229
|
file: await readGitHistoryFileContentRange(await this.gitRepository(input), input.commit, input.path, input.offset, input.limit, this.refs(input))
|
|
218
230
|
};
|
|
219
231
|
}
|
|
232
|
+
async gitCommitFileDirectMetadata(input) {
|
|
233
|
+
return {
|
|
234
|
+
file: await this.gitDirectMetadata(input)
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
async directDownload(descriptor) {
|
|
238
|
+
const workspace = this.options.requireWorkspace(descriptor.resource.workspaceId);
|
|
239
|
+
if (descriptor.resource.kind === 'WORKSPACE_FILE') {
|
|
240
|
+
const input = {
|
|
241
|
+
workspaceId: workspace.id,
|
|
242
|
+
path: descriptor.resource.path,
|
|
243
|
+
...(descriptor.resource.worktreePath === undefined
|
|
244
|
+
? {}
|
|
245
|
+
: { worktreePath: descriptor.resource.worktreePath })
|
|
246
|
+
};
|
|
247
|
+
return {
|
|
248
|
+
offset: descriptor.resumeOffset ?? 0,
|
|
249
|
+
chunks: this.options.files.readDirect(workspace, {
|
|
250
|
+
path: descriptor.resource.path,
|
|
251
|
+
size: descriptor.size,
|
|
252
|
+
sha256: descriptor.sha256
|
|
253
|
+
}, await this.fileRepository(workspace, input))
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const input = {
|
|
257
|
+
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 })
|
|
266
|
+
};
|
|
267
|
+
const repository = await this.gitRepository(input);
|
|
268
|
+
const content = await openGitHistoryFileContent(repository, descriptor.resource.commit, descriptor.resource.path, this.refs(input));
|
|
269
|
+
if (content.size !== descriptor.size)
|
|
270
|
+
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
271
|
+
return {
|
|
272
|
+
offset: descriptor.resumeOffset ?? 0,
|
|
273
|
+
chunks: (async function* () {
|
|
274
|
+
const hash = createHash('sha256');
|
|
275
|
+
let offset = 0;
|
|
276
|
+
for await (const bytes of content.chunks) {
|
|
277
|
+
hash.update(bytes);
|
|
278
|
+
offset += bytes.length;
|
|
279
|
+
if (bytes.length > 0)
|
|
280
|
+
yield bytes;
|
|
281
|
+
}
|
|
282
|
+
if (offset !== descriptor.size || hash.digest('hex') !== descriptor.sha256)
|
|
283
|
+
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
284
|
+
})()
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
async gitDirectMetadata(input) {
|
|
288
|
+
const repository = await this.gitRepository(input);
|
|
289
|
+
if (typeof input.commit !== 'string' || typeof input.path !== 'string')
|
|
290
|
+
throw new Error('GIT_HISTORY_COMMIT_INVALID');
|
|
291
|
+
const content = await openGitHistoryFileContent(repository, input.commit, input.path, this.refs(input));
|
|
292
|
+
const hash = createHash('sha256');
|
|
293
|
+
let offset = 0;
|
|
294
|
+
for await (const bytes of content.chunks) {
|
|
295
|
+
hash.update(bytes);
|
|
296
|
+
offset += bytes.length;
|
|
297
|
+
}
|
|
298
|
+
if (offset !== content.size)
|
|
299
|
+
throw new Error('DIRECT_FILE_REVISION_CHANGED');
|
|
300
|
+
return { size: content.size, sha256: hash.digest('hex') };
|
|
301
|
+
}
|
|
220
302
|
async diff(input) {
|
|
221
303
|
if (typeof input.workspaceId !== 'string' ||
|
|
222
304
|
typeof input.fromRef !== 'string' ||
|
package/dist/workspace.js
CHANGED
|
@@ -1510,6 +1510,67 @@ export async function readGitHistoryFileContentRange(repository, commit, request
|
|
|
1510
1510
|
nextOffset: nextOffset < size ? nextOffset : null
|
|
1511
1511
|
};
|
|
1512
1512
|
}
|
|
1513
|
+
export async function openGitHistoryFileContent(repository, commit, requestedPath, refs) {
|
|
1514
|
+
await assertHistoryCommitReachable(repository, commit, refs);
|
|
1515
|
+
const path = safeGitWorkspacePath(requestedPath);
|
|
1516
|
+
const object = `${commit}:${path}`;
|
|
1517
|
+
const sizeResult = await runReadOnlyGit(repository, ['cat-file', '-s', object]);
|
|
1518
|
+
const size = Number(sizeResult.text.trim());
|
|
1519
|
+
if (!Number.isSafeInteger(size) || size < 0)
|
|
1520
|
+
throw new Error('FILE_RANGE_INVALID');
|
|
1521
|
+
return {
|
|
1522
|
+
path,
|
|
1523
|
+
size,
|
|
1524
|
+
chunks: (async function* () {
|
|
1525
|
+
const child = spawn('git', ['cat-file', 'blob', object], {
|
|
1526
|
+
cwd: repository,
|
|
1527
|
+
shell: false,
|
|
1528
|
+
windowsHide: true,
|
|
1529
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1530
|
+
env: {
|
|
1531
|
+
...process.env,
|
|
1532
|
+
...gitOperationEnvironment.getStore(),
|
|
1533
|
+
GIT_OPTIONAL_LOCKS: '0'
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
const errors = [];
|
|
1537
|
+
let errorSize = 0;
|
|
1538
|
+
let closed = false;
|
|
1539
|
+
const exit = new Promise((resolveExit) => {
|
|
1540
|
+
child.stderr.on('data', (chunk) => {
|
|
1541
|
+
const remaining = Math.max(0, 4096 - errorSize);
|
|
1542
|
+
if (remaining === 0)
|
|
1543
|
+
return;
|
|
1544
|
+
const retained = chunk.subarray(0, remaining);
|
|
1545
|
+
errors.push(retained);
|
|
1546
|
+
errorSize += retained.length;
|
|
1547
|
+
});
|
|
1548
|
+
child.once('error', (error) => {
|
|
1549
|
+
closed = true;
|
|
1550
|
+
resolveExit({ code: 1, stderr: error.message });
|
|
1551
|
+
});
|
|
1552
|
+
child.once('close', (code) => {
|
|
1553
|
+
closed = true;
|
|
1554
|
+
resolveExit({
|
|
1555
|
+
code: code ?? 1,
|
|
1556
|
+
stderr: Buffer.concat(errors).toString('utf8')
|
|
1557
|
+
});
|
|
1558
|
+
});
|
|
1559
|
+
});
|
|
1560
|
+
try {
|
|
1561
|
+
for await (const chunk of child.stdout)
|
|
1562
|
+
yield new Uint8Array(chunk);
|
|
1563
|
+
const result = await exit;
|
|
1564
|
+
if (result.code !== 0)
|
|
1565
|
+
throw new GitCommandError(result.code, result.stderr);
|
|
1566
|
+
}
|
|
1567
|
+
finally {
|
|
1568
|
+
if (!closed)
|
|
1569
|
+
child.kill('SIGKILL');
|
|
1570
|
+
}
|
|
1571
|
+
})()
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1513
1574
|
export async function readCurrentChanges(workspacePath) {
|
|
1514
1575
|
return (await readCurrentChangesSummary(workspacePath)).changes;
|
|
1515
1576
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myagentroam/node",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.69",
|
|
4
4
|
"description": "MyAgentRoam Node runtime CLI.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -24,11 +24,12 @@
|
|
|
24
24
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
25
25
|
"@opencode-ai/sdk": "1.18.20",
|
|
26
26
|
"minimatch": "^10.2.6",
|
|
27
|
+
"node-datachannel": "0.33.4",
|
|
27
28
|
"node-pty": "1.1.0",
|
|
28
29
|
"ws": "^8.21.3",
|
|
29
30
|
"zod": "4.4.3",
|
|
30
|
-
"@myagentroam/agent": "0.9.
|
|
31
|
-
"@myagentroam/protocol": "0.9.
|
|
31
|
+
"@myagentroam/agent": "0.9.69",
|
|
32
|
+
"@myagentroam/protocol": "0.9.69"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"@types/ws": "^8.18.1"
|