@livedesk/hub 0.1.46 → 0.1.48
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/package.json +2 -2
- package/src/agents/agent-result-enrichment.js +66 -66
- package/src/agents/codex-agent-runtime.js +4 -4
- package/src/console-relay.js +424 -424
- package/src/filesystem/roots.js +20 -2
- package/src/live-desk-update.js +24 -24
- package/src/remote-clipboard-contract.mjs +482 -0
- package/src/remote-hub.js +1137 -216
- package/src/server.js +227 -99
- package/src/settings/effective-device-policy.js +31 -9
- package/src/settings/settings-schema.js +3 -2
package/src/filesystem/roots.js
CHANGED
|
@@ -98,8 +98,26 @@ async function mountedRoots() {
|
|
|
98
98
|
return roots;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
export async function discoverFilesystemRoots() {
|
|
102
|
-
const
|
|
101
|
+
export async function discoverFilesystemRoots() {
|
|
102
|
+
const isolatedTestRoot = process.env.LIVEDESK_TEST_MODE === '1'
|
|
103
|
+
? normalizeRootPath(process.env.LIVEDESK_TEST_FILESYSTEM_ROOT)
|
|
104
|
+
: '';
|
|
105
|
+
if (isolatedTestRoot) {
|
|
106
|
+
const rootStat = await fs.stat(isolatedTestRoot);
|
|
107
|
+
if (!rootStat.isDirectory()) {
|
|
108
|
+
throw new Error('LIVEDESK_TEST_FILESYSTEM_ROOT must be a directory.');
|
|
109
|
+
}
|
|
110
|
+
return [{
|
|
111
|
+
path: isolatedTestRoot,
|
|
112
|
+
name: 'Test drive',
|
|
113
|
+
displayPath: isolatedTestRoot,
|
|
114
|
+
type: 'drive',
|
|
115
|
+
driveType: 'fixed',
|
|
116
|
+
...await statfsCapacity(isolatedTestRoot),
|
|
117
|
+
hasChildren: true
|
|
118
|
+
}];
|
|
119
|
+
}
|
|
120
|
+
const roots = process.platform === 'win32' ? await windowsRoots() : await mountedRoots();
|
|
103
121
|
if (roots.length > 0) return roots;
|
|
104
122
|
const fallback = process.platform === 'win32' ? `${process.env.SystemDrive || 'C:'}\\` : os.homedir();
|
|
105
123
|
return [{
|
package/src/live-desk-update.js
CHANGED
|
@@ -241,11 +241,11 @@ function resetReconnectCandidate(target) {
|
|
|
241
241
|
target.stabilityDeadlineAt = '';
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
export function createLiveDeskUpdateManager({
|
|
245
|
-
remoteHub,
|
|
246
|
-
currentManagerVersion,
|
|
247
|
-
currentClientVersion,
|
|
248
|
-
excludedDeviceIds = [],
|
|
244
|
+
export function createLiveDeskUpdateManager({
|
|
245
|
+
remoteHub,
|
|
246
|
+
currentManagerVersion,
|
|
247
|
+
currentClientVersion,
|
|
248
|
+
excludedDeviceIds = [],
|
|
249
249
|
restartSupported = false,
|
|
250
250
|
requestHubRestart,
|
|
251
251
|
fetchImpl = globalThis.fetch,
|
|
@@ -261,16 +261,16 @@ export function createLiveDeskUpdateManager({
|
|
|
261
261
|
let checkPromise = null;
|
|
262
262
|
let run = null;
|
|
263
263
|
let checkTimer = null;
|
|
264
|
-
let runTimer = null;
|
|
265
|
-
const excludedDeviceIdSet = new Set(
|
|
266
|
-
(Array.isArray(excludedDeviceIds) ? excludedDeviceIds : [excludedDeviceIds])
|
|
267
|
-
.map(value => String(value || '').trim())
|
|
268
|
-
.filter(Boolean)
|
|
269
|
-
);
|
|
270
|
-
const connectedClientDevices = () => connectedDevices(remoteHub)
|
|
271
|
-
.filter(device => !excludedDeviceIdSet.has(String(device?.deviceId || '')));
|
|
272
|
-
const knownOfflineClientDevices = () => knownOfflineDevices(remoteHub)
|
|
273
|
-
.filter(device => !excludedDeviceIdSet.has(String(device?.deviceId || '')));
|
|
264
|
+
let runTimer = null;
|
|
265
|
+
const excludedDeviceIdSet = new Set(
|
|
266
|
+
(Array.isArray(excludedDeviceIds) ? excludedDeviceIds : [excludedDeviceIds])
|
|
267
|
+
.map(value => String(value || '').trim())
|
|
268
|
+
.filter(Boolean)
|
|
269
|
+
);
|
|
270
|
+
const connectedClientDevices = () => connectedDevices(remoteHub)
|
|
271
|
+
.filter(device => !excludedDeviceIdSet.has(String(device?.deviceId || '')));
|
|
272
|
+
const knownOfflineClientDevices = () => knownOfflineDevices(remoteHub)
|
|
273
|
+
.filter(device => !excludedDeviceIdSet.has(String(device?.deviceId || '')));
|
|
274
274
|
const effectiveClientBatchSize = Math.max(
|
|
275
275
|
1,
|
|
276
276
|
Math.min(50, Math.floor(Number(clientBatchSize) || LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE))
|
|
@@ -321,7 +321,7 @@ export function createLiveDeskUpdateManager({
|
|
|
321
321
|
const getStatus = () => {
|
|
322
322
|
const managerUpdateAvailable = !!latestRelease
|
|
323
323
|
&& compareVersions(latestRelease.latestManagerVersion, currentManagerVersion) > 0;
|
|
324
|
-
const clientDevices = connectedClientDevices();
|
|
324
|
+
const clientDevices = connectedClientDevices();
|
|
325
325
|
const outdatedClientDevices = latestRelease
|
|
326
326
|
? clientDevices.filter(device => deviceNeedsUpdate(
|
|
327
327
|
device,
|
|
@@ -330,7 +330,7 @@ export function createLiveDeskUpdateManager({
|
|
|
330
330
|
))
|
|
331
331
|
: [];
|
|
332
332
|
const pendingOfflineClientCount = latestRelease
|
|
333
|
-
? knownOfflineClientDevices().filter(device => deviceNeedsUpdate(
|
|
333
|
+
? knownOfflineClientDevices().filter(device => deviceNeedsUpdate(
|
|
334
334
|
device,
|
|
335
335
|
latestRelease.latestManagerVersion,
|
|
336
336
|
latestRelease.latestClientVersion
|
|
@@ -414,9 +414,9 @@ export function createLiveDeskUpdateManager({
|
|
|
414
414
|
if (!run || run.state !== 'failed') return [];
|
|
415
415
|
const currentTime = now();
|
|
416
416
|
const devices = new Map(
|
|
417
|
-
remoteHub.listDevices({ includeDataUrl: false })
|
|
418
|
-
.filter(device => !excludedDeviceIdSet.has(String(device?.deviceId || '')))
|
|
419
|
-
.map(device => [String(device.deviceId), device])
|
|
417
|
+
remoteHub.listDevices({ includeDataUrl: false })
|
|
418
|
+
.filter(device => !excludedDeviceIdSet.has(String(device?.deviceId || '')))
|
|
419
|
+
.map(device => [String(device.deviceId), device])
|
|
420
420
|
);
|
|
421
421
|
return run.targets.filter(target => {
|
|
422
422
|
if (target.state !== 'failed' || !target.dispatchedAt) return false;
|
|
@@ -523,7 +523,7 @@ export function createLiveDeskUpdateManager({
|
|
|
523
523
|
const activeCount = run.targets.filter(target => target.state === 'waiting').length;
|
|
524
524
|
let availableSlots = Math.max(0, run.batchSize - activeCount);
|
|
525
525
|
if (availableSlots <= 0) return true;
|
|
526
|
-
const devices = new Map(connectedClientDevices().map(device => [String(device.deviceId), device]));
|
|
526
|
+
const devices = new Map(connectedClientDevices().map(device => [String(device.deviceId), device]));
|
|
527
527
|
for (const target of run.targets) {
|
|
528
528
|
if (availableSlots <= 0) break;
|
|
529
529
|
if (!['queued', 'pending-offline'].includes(target.state)) continue;
|
|
@@ -562,7 +562,7 @@ export function createLiveDeskUpdateManager({
|
|
|
562
562
|
failRun(`Timed out waiting for ${unfinishedCount} client(s) to finish the rollout.`);
|
|
563
563
|
return;
|
|
564
564
|
}
|
|
565
|
-
const devices = new Map(connectedClientDevices().map(device => [String(device.deviceId), device]));
|
|
565
|
+
const devices = new Map(connectedClientDevices().map(device => [String(device.deviceId), device]));
|
|
566
566
|
for (const target of run.targets) {
|
|
567
567
|
if (target.state === 'failed' || target.state === 'completed') continue;
|
|
568
568
|
const device = devices.get(target.deviceId);
|
|
@@ -689,7 +689,7 @@ export function createLiveDeskUpdateManager({
|
|
|
689
689
|
if (!release) return { ok: false, error: checkError || 'LiveDesk update check failed.', ...getStatus() };
|
|
690
690
|
const managerNeedsUpdate = compareVersions(release.latestManagerVersion, currentManagerVersion) > 0;
|
|
691
691
|
const clientPackageNeedsUpdate = compareVersions(release.latestClientVersion, currentClientVersion) > 0;
|
|
692
|
-
const connected = connectedClientDevices();
|
|
692
|
+
const connected = connectedClientDevices();
|
|
693
693
|
const outdatedClients = connected.filter(device => deviceNeedsUpdate(
|
|
694
694
|
device,
|
|
695
695
|
release.latestManagerVersion,
|
|
@@ -790,7 +790,7 @@ export function createLiveDeskUpdateManager({
|
|
|
790
790
|
if (target.method === 'dedicated'
|
|
791
791
|
&& target.bootstrapRetryCount === 0
|
|
792
792
|
&& isDedicatedBootstrapFailure(failure)) {
|
|
793
|
-
const retryDevice = connectedClientDevices()
|
|
793
|
+
const retryDevice = connectedClientDevices()
|
|
794
794
|
.find(device => String(device.deviceId || '') === target.deviceId);
|
|
795
795
|
const remainingMs = Date.parse(target.deadlineAt || '') - now();
|
|
796
796
|
if (retryDevice?.connected === true && remainingMs >= 10_000) {
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
const MIB = 1024 * 1024;
|
|
2
|
+
|
|
3
|
+
export const REMOTE_CLIPBOARD_PROTOCOL = 'livedesk.clipboard.v1';
|
|
4
|
+
|
|
5
|
+
export const REMOTE_CLIPBOARD_LIMITS = Object.freeze({
|
|
6
|
+
maxTextBytes: 1 * MIB,
|
|
7
|
+
maxImageBytes: 24 * MIB,
|
|
8
|
+
maxTotalBytes: 256 * MIB,
|
|
9
|
+
maxFiles: 24,
|
|
10
|
+
// 384,000 raw bytes encode to 512,000 base64 characters, leaving bounded
|
|
11
|
+
// room for the command envelope below Relay's 512 KiB JSON-message limit.
|
|
12
|
+
maxChunkBytes: 384_000,
|
|
13
|
+
maxOperationsPerDevice: 2,
|
|
14
|
+
operationIdleTimeoutMs: 2 * 60 * 1000,
|
|
15
|
+
commandTimeoutMs: 15 * 1000
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const REMOTE_CLIPBOARD_INPUT_ACTIONS = Object.freeze(new Set([
|
|
19
|
+
'clipboard.prepare',
|
|
20
|
+
'clipboard.copy',
|
|
21
|
+
'clipboard.paste'
|
|
22
|
+
]));
|
|
23
|
+
|
|
24
|
+
export const REMOTE_CLIPBOARD_FILE_ACTIONS = Object.freeze(new Set([
|
|
25
|
+
'clipboard.copy.begin',
|
|
26
|
+
'clipboard.copy.chunk',
|
|
27
|
+
'clipboard.copy.end',
|
|
28
|
+
'clipboard.paste.begin',
|
|
29
|
+
'clipboard.paste.chunk',
|
|
30
|
+
'clipboard.paste.cancel',
|
|
31
|
+
'clipboard.operation.status'
|
|
32
|
+
]));
|
|
33
|
+
|
|
34
|
+
const ACTION_ALIASES = new Map([
|
|
35
|
+
['prepare', 'clipboard.prepare'],
|
|
36
|
+
['copy', 'clipboard.copy'],
|
|
37
|
+
['paste', 'clipboard.paste'],
|
|
38
|
+
['copy.begin', 'clipboard.copy.begin'],
|
|
39
|
+
['copy.chunk', 'clipboard.copy.chunk'],
|
|
40
|
+
['copy.end', 'clipboard.copy.end'],
|
|
41
|
+
['paste.begin', 'clipboard.paste.begin'],
|
|
42
|
+
['paste.chunk', 'clipboard.paste.chunk'],
|
|
43
|
+
['paste.cancel', 'clipboard.paste.cancel'],
|
|
44
|
+
['operation.status', 'clipboard.operation.status']
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const SAFE_STATUS_VALUES = new Set([
|
|
48
|
+
'preparing',
|
|
49
|
+
'copying',
|
|
50
|
+
'receiving',
|
|
51
|
+
'ready',
|
|
52
|
+
'completed',
|
|
53
|
+
'cancelled',
|
|
54
|
+
'cleaned',
|
|
55
|
+
'failed',
|
|
56
|
+
'expired',
|
|
57
|
+
'not-found',
|
|
58
|
+
'already-ended',
|
|
59
|
+
'complete',
|
|
60
|
+
'applied',
|
|
61
|
+
'pasted'
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
export class RemoteClipboardContractError extends Error {
|
|
65
|
+
constructor(code, status = 400) {
|
|
66
|
+
super(code);
|
|
67
|
+
this.name = 'RemoteClipboardContractError';
|
|
68
|
+
this.code = code;
|
|
69
|
+
this.status = status;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fail(code, status = 400) {
|
|
74
|
+
throw new RemoteClipboardContractError(code, status);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function boundedString(value, maxLength, code, { required = false } = {}) {
|
|
78
|
+
const text = String(value ?? '').trim();
|
|
79
|
+
if ((required && !text) || text.length > maxLength) {
|
|
80
|
+
fail(code);
|
|
81
|
+
}
|
|
82
|
+
return text;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function exactInteger(value, { min = 0, max = Number.MAX_SAFE_INTEGER, code }) {
|
|
86
|
+
const number = Number(value);
|
|
87
|
+
if (!Number.isSafeInteger(number) || number < min || number > max) {
|
|
88
|
+
fail(code);
|
|
89
|
+
}
|
|
90
|
+
return number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function optionalInteger(value, options) {
|
|
94
|
+
if (value === undefined || value === null || value === '') {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
return exactInteger(value, options);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeContentKind(value) {
|
|
101
|
+
const kind = String(value || '').trim().toLowerCase();
|
|
102
|
+
if (!['text', 'image', 'files'].includes(kind)) {
|
|
103
|
+
fail('clipboard-manifest-kind-invalid');
|
|
104
|
+
}
|
|
105
|
+
return kind;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeItemKind(value) {
|
|
109
|
+
const kind = String(value || '').trim().toLowerCase();
|
|
110
|
+
if (!['text', 'image', 'file'].includes(kind)) {
|
|
111
|
+
fail('clipboard-manifest-item-kind-invalid');
|
|
112
|
+
}
|
|
113
|
+
return kind;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeMimeType(value, itemKind) {
|
|
117
|
+
const fallback = itemKind === 'text'
|
|
118
|
+
? 'text/plain;charset=utf-8'
|
|
119
|
+
: itemKind === 'image'
|
|
120
|
+
? 'image/png'
|
|
121
|
+
: 'application/octet-stream';
|
|
122
|
+
const mimeType = boundedString(value || fallback, 160, 'clipboard-manifest-mime-invalid');
|
|
123
|
+
if (itemKind === 'text' && !mimeType.toLowerCase().startsWith('text/plain')) {
|
|
124
|
+
fail('clipboard-text-mime-required');
|
|
125
|
+
}
|
|
126
|
+
if (itemKind === 'image' && mimeType.toLowerCase() !== 'image/png') {
|
|
127
|
+
fail('clipboard-image-png-required');
|
|
128
|
+
}
|
|
129
|
+
return mimeType;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function normalizeRemoteClipboardOperationId(value) {
|
|
133
|
+
const operationId = boundedString(value, 128, 'clipboard-operation-id-invalid', { required: true });
|
|
134
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(operationId)) {
|
|
135
|
+
fail('clipboard-operation-id-invalid');
|
|
136
|
+
}
|
|
137
|
+
return operationId;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function normalizeRemoteClipboardAction(value) {
|
|
141
|
+
const requested = String(value || '').trim().toLowerCase();
|
|
142
|
+
const action = ACTION_ALIASES.get(requested) || requested;
|
|
143
|
+
if (!REMOTE_CLIPBOARD_INPUT_ACTIONS.has(action) && !REMOTE_CLIPBOARD_FILE_ACTIONS.has(action)) {
|
|
144
|
+
fail('clipboard-action-invalid');
|
|
145
|
+
}
|
|
146
|
+
return action;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function normalizeRemoteClipboardManifest(value, expectedOperationId = '') {
|
|
150
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
151
|
+
fail('clipboard-manifest-required');
|
|
152
|
+
}
|
|
153
|
+
const operationId = normalizeRemoteClipboardOperationId(
|
|
154
|
+
value.operationId || value.clipboardOperationId || expectedOperationId);
|
|
155
|
+
if (expectedOperationId && operationId !== expectedOperationId) {
|
|
156
|
+
fail('clipboard-operation-id-mismatch');
|
|
157
|
+
}
|
|
158
|
+
const contentKind = normalizeContentKind(value.contentKind || value.kind);
|
|
159
|
+
const rawItems = Array.isArray(value.items) ? value.items : [];
|
|
160
|
+
if (rawItems.length === 0 || rawItems.length > REMOTE_CLIPBOARD_LIMITS.maxFiles) {
|
|
161
|
+
fail('clipboard-manifest-item-count-invalid');
|
|
162
|
+
}
|
|
163
|
+
const expectedItemKind = contentKind === 'files' ? 'file' : contentKind;
|
|
164
|
+
if (contentKind !== 'files' && rawItems.length !== 1) {
|
|
165
|
+
fail('clipboard-single-item-required');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let totalBytes = 0;
|
|
169
|
+
const items = rawItems.map((rawItem, itemIndex) => {
|
|
170
|
+
const item = rawItem && typeof rawItem === 'object' && !Array.isArray(rawItem) ? rawItem : {};
|
|
171
|
+
const normalizedIndex = exactInteger(item.itemIndex, {
|
|
172
|
+
min: 0,
|
|
173
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxFiles - 1,
|
|
174
|
+
code: 'clipboard-manifest-item-index-invalid'
|
|
175
|
+
});
|
|
176
|
+
if (normalizedIndex !== itemIndex) {
|
|
177
|
+
fail('clipboard-manifest-item-index-invalid');
|
|
178
|
+
}
|
|
179
|
+
const kind = normalizeItemKind(item.kind);
|
|
180
|
+
if (kind !== expectedItemKind) {
|
|
181
|
+
fail('clipboard-manifest-item-kind-mismatch');
|
|
182
|
+
}
|
|
183
|
+
const size = exactInteger(item.size, {
|
|
184
|
+
min: 0,
|
|
185
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
|
|
186
|
+
code: 'clipboard-manifest-item-size-invalid'
|
|
187
|
+
});
|
|
188
|
+
if (kind !== 'file' && size === 0) {
|
|
189
|
+
fail('clipboard-manifest-item-size-invalid');
|
|
190
|
+
}
|
|
191
|
+
if (kind === 'text' && size > REMOTE_CLIPBOARD_LIMITS.maxTextBytes) {
|
|
192
|
+
fail('clipboard-text-too-large', 413);
|
|
193
|
+
}
|
|
194
|
+
if (kind === 'image' && size > REMOTE_CLIPBOARD_LIMITS.maxImageBytes) {
|
|
195
|
+
fail('clipboard-image-too-large', 413);
|
|
196
|
+
}
|
|
197
|
+
totalBytes += size;
|
|
198
|
+
if (totalBytes > REMOTE_CLIPBOARD_LIMITS.maxTotalBytes) {
|
|
199
|
+
fail('clipboard-total-too-large', 413);
|
|
200
|
+
}
|
|
201
|
+
const storageName = boundedString(
|
|
202
|
+
item.storageName,
|
|
203
|
+
64,
|
|
204
|
+
'clipboard-manifest-storage-name-invalid',
|
|
205
|
+
{ required: true });
|
|
206
|
+
if (storageName !== `item-${itemIndex}.bin`) {
|
|
207
|
+
fail('clipboard-manifest-storage-name-invalid');
|
|
208
|
+
}
|
|
209
|
+
const sha256 = boundedString(item.sha256, 64, 'clipboard-manifest-sha256-invalid', { required: true }).toLowerCase();
|
|
210
|
+
if (!/^[a-f0-9]{64}$/.test(sha256)) {
|
|
211
|
+
fail('clipboard-manifest-sha256-invalid');
|
|
212
|
+
}
|
|
213
|
+
const name = boundedString(
|
|
214
|
+
item.name || (kind === 'text' ? 'clipboard.txt' : kind === 'image' ? 'clipboard.png' : ''),
|
|
215
|
+
240,
|
|
216
|
+
'clipboard-manifest-name-invalid',
|
|
217
|
+
{ required: true });
|
|
218
|
+
if (/[/\\\u0000-\u001f]/.test(name) || name === '.' || name === '..') {
|
|
219
|
+
fail('clipboard-manifest-name-invalid');
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
itemIndex,
|
|
223
|
+
kind,
|
|
224
|
+
name,
|
|
225
|
+
mimeType: normalizeMimeType(item.mimeType, kind),
|
|
226
|
+
size,
|
|
227
|
+
lastModified: optionalInteger(item.lastModified, {
|
|
228
|
+
min: 0,
|
|
229
|
+
max: 8_640_000_000_000_000,
|
|
230
|
+
code: 'clipboard-manifest-last-modified-invalid'
|
|
231
|
+
}) || 0,
|
|
232
|
+
sha256,
|
|
233
|
+
storageName
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const declaredTotalBytes = optionalInteger(value.totalBytes, {
|
|
238
|
+
min: 0,
|
|
239
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
|
|
240
|
+
code: 'clipboard-manifest-total-invalid'
|
|
241
|
+
});
|
|
242
|
+
if (declaredTotalBytes !== undefined && declaredTotalBytes !== totalBytes) {
|
|
243
|
+
fail('clipboard-manifest-total-mismatch');
|
|
244
|
+
}
|
|
245
|
+
return { operationId, contentKind, totalBytes, items };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function normalizeRemoteClipboardBase64(value, maxBytes = REMOTE_CLIPBOARD_LIMITS.maxChunkBytes) {
|
|
249
|
+
const encoded = String(value || '').trim();
|
|
250
|
+
if (encoded.length > Math.ceil(maxBytes / 3) * 4 + 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) {
|
|
251
|
+
fail('clipboard-chunk-base64-invalid');
|
|
252
|
+
}
|
|
253
|
+
const bytes = Buffer.from(encoded, 'base64');
|
|
254
|
+
if (bytes.length > maxBytes) {
|
|
255
|
+
fail('clipboard-chunk-too-large', 413);
|
|
256
|
+
}
|
|
257
|
+
if (bytes.toString('base64') !== encoded) {
|
|
258
|
+
fail('clipboard-chunk-base64-invalid');
|
|
259
|
+
}
|
|
260
|
+
return { dataBase64: encoded, byteLength: bytes.length };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function normalizeBinding(body) {
|
|
264
|
+
const binding = body?.binding && typeof body.binding === 'object' && !Array.isArray(body.binding)
|
|
265
|
+
? body.binding
|
|
266
|
+
: {};
|
|
267
|
+
return {
|
|
268
|
+
controlSessionId: boundedString(
|
|
269
|
+
binding.sessionId || binding.controlSessionId || body.controlSessionId || body.sessionId,
|
|
270
|
+
160,
|
|
271
|
+
'clipboard-control-session-required',
|
|
272
|
+
{ required: true }),
|
|
273
|
+
controlCommandId: boundedString(
|
|
274
|
+
binding.commandId || binding.controlCommandId || body.controlCommandId || body.commandId,
|
|
275
|
+
128,
|
|
276
|
+
'clipboard-control-command-required',
|
|
277
|
+
{ required: true }),
|
|
278
|
+
captureGeneration: exactInteger(
|
|
279
|
+
binding.captureGeneration ?? body.captureGeneration,
|
|
280
|
+
{ min: 1, max: Number.MAX_SAFE_INTEGER, code: 'clipboard-capture-generation-required' }),
|
|
281
|
+
monitorIndex: exactInteger(
|
|
282
|
+
binding.monitorIndex ?? body.monitorIndex,
|
|
283
|
+
{ min: 0, max: 63, code: 'clipboard-monitor-index-required' })
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function normalizeClipboardPayload(action, rawPayload, operationId) {
|
|
288
|
+
const payload = rawPayload && typeof rawPayload === 'object' && !Array.isArray(rawPayload)
|
|
289
|
+
? rawPayload
|
|
290
|
+
: {};
|
|
291
|
+
if (action === 'clipboard.paste.begin') {
|
|
292
|
+
return { manifest: normalizeRemoteClipboardManifest(payload.manifest || payload, operationId) };
|
|
293
|
+
}
|
|
294
|
+
if (action === 'clipboard.copy.chunk') {
|
|
295
|
+
return {
|
|
296
|
+
itemIndex: exactInteger(payload.itemIndex, {
|
|
297
|
+
min: 0,
|
|
298
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxFiles - 1,
|
|
299
|
+
code: 'clipboard-item-index-invalid'
|
|
300
|
+
}),
|
|
301
|
+
offset: exactInteger(payload.offset, {
|
|
302
|
+
min: 0,
|
|
303
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
|
|
304
|
+
code: 'clipboard-chunk-offset-invalid'
|
|
305
|
+
}),
|
|
306
|
+
maxBytes: exactInteger(payload.maxBytes ?? REMOTE_CLIPBOARD_LIMITS.maxChunkBytes, {
|
|
307
|
+
min: 1,
|
|
308
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxChunkBytes,
|
|
309
|
+
code: 'clipboard-chunk-max-bytes-invalid'
|
|
310
|
+
})
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (action === 'clipboard.paste.chunk') {
|
|
314
|
+
const chunk = normalizeRemoteClipboardBase64(payload.dataBase64);
|
|
315
|
+
return {
|
|
316
|
+
itemIndex: exactInteger(payload.itemIndex, {
|
|
317
|
+
min: 0,
|
|
318
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxFiles - 1,
|
|
319
|
+
code: 'clipboard-item-index-invalid'
|
|
320
|
+
}),
|
|
321
|
+
offset: exactInteger(payload.offset, {
|
|
322
|
+
min: 0,
|
|
323
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
|
|
324
|
+
code: 'clipboard-chunk-offset-invalid'
|
|
325
|
+
}),
|
|
326
|
+
dataBase64: chunk.dataBase64,
|
|
327
|
+
final: payload.final === true
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const normalized = {};
|
|
331
|
+
const manifestValue = payload.manifest;
|
|
332
|
+
if (manifestValue) {
|
|
333
|
+
normalized.manifest = normalizeRemoteClipboardManifest(manifestValue, operationId);
|
|
334
|
+
}
|
|
335
|
+
const requestedKind = payload.contentKind || payload.kind;
|
|
336
|
+
if (requestedKind) {
|
|
337
|
+
normalized.contentKind = normalizeContentKind(requestedKind);
|
|
338
|
+
}
|
|
339
|
+
return normalized;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function normalizeRemoteClipboardRequest(value) {
|
|
343
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
344
|
+
fail('clipboard-request-invalid');
|
|
345
|
+
}
|
|
346
|
+
const action = normalizeRemoteClipboardAction(value.action || value.type);
|
|
347
|
+
const operationId = normalizeRemoteClipboardOperationId(
|
|
348
|
+
value.operationId || value.clipboardOperationId || value.op?.operationId || value.op);
|
|
349
|
+
const hubConnectionId = boundedString(
|
|
350
|
+
value.hubConnectionId || value.binding?.hubConnectionId,
|
|
351
|
+
128,
|
|
352
|
+
'clipboard-input-owner-required',
|
|
353
|
+
{ required: true });
|
|
354
|
+
const clipboardDirection = action.includes('.paste')
|
|
355
|
+
? 'paste'
|
|
356
|
+
: action.includes('.copy')
|
|
357
|
+
? 'copy'
|
|
358
|
+
: String(value.clipboardDirection || value.payload?.clipboardDirection || '').trim().toLowerCase();
|
|
359
|
+
if (action === 'clipboard.prepare' && !['copy', 'paste'].includes(clipboardDirection)) {
|
|
360
|
+
fail('clipboard-direction-required');
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
action,
|
|
364
|
+
operationId,
|
|
365
|
+
hubConnectionId,
|
|
366
|
+
clipboardDirection,
|
|
367
|
+
binding: normalizeBinding(value),
|
|
368
|
+
payload: normalizeClipboardPayload(action, value.payload, operationId)
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function remoteClipboardContentNeedsFileTransfer(contentKind) {
|
|
373
|
+
return contentKind === 'image' || contentKind === 'files';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function safeStatus(value) {
|
|
377
|
+
const status = String(value || '').trim().toLowerCase();
|
|
378
|
+
return SAFE_STATUS_VALUES.has(status) ? status : '';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function normalizeRemoteClipboardCommandResult(action, operationId, value) {
|
|
382
|
+
const result = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
383
|
+
const returnedOperationId = result.operationId || result.clipboardOperationId;
|
|
384
|
+
if (returnedOperationId && normalizeRemoteClipboardOperationId(returnedOperationId) !== operationId) {
|
|
385
|
+
fail('clipboard-operation-id-mismatch', 409);
|
|
386
|
+
}
|
|
387
|
+
const response = {
|
|
388
|
+
ok: result.ok !== false,
|
|
389
|
+
action,
|
|
390
|
+
operationId,
|
|
391
|
+
status: safeStatus(result.status) || (result.ok === false ? 'failed' : '')
|
|
392
|
+
};
|
|
393
|
+
if (action === 'clipboard.copy.begin') {
|
|
394
|
+
response.manifest = normalizeRemoteClipboardManifest(result.manifest, operationId);
|
|
395
|
+
response.kind = response.manifest.contentKind;
|
|
396
|
+
response.status ||= 'ready';
|
|
397
|
+
return response;
|
|
398
|
+
}
|
|
399
|
+
if (action === 'clipboard.copy.chunk') {
|
|
400
|
+
const chunk = normalizeRemoteClipboardBase64(result.dataBase64);
|
|
401
|
+
const itemIndex = exactInteger(result.itemIndex, {
|
|
402
|
+
min: 0,
|
|
403
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxFiles - 1,
|
|
404
|
+
code: 'clipboard-item-index-invalid'
|
|
405
|
+
});
|
|
406
|
+
const offset = exactInteger(result.offset, {
|
|
407
|
+
min: 0,
|
|
408
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
|
|
409
|
+
code: 'clipboard-chunk-offset-invalid'
|
|
410
|
+
});
|
|
411
|
+
const bytes = exactInteger(result.bytes ?? chunk.byteLength, {
|
|
412
|
+
min: 0,
|
|
413
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxChunkBytes,
|
|
414
|
+
code: 'clipboard-chunk-byte-count-invalid'
|
|
415
|
+
});
|
|
416
|
+
if (bytes !== chunk.byteLength) {
|
|
417
|
+
fail('clipboard-chunk-byte-count-mismatch');
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
ok: result.ok !== false,
|
|
421
|
+
action,
|
|
422
|
+
operationId,
|
|
423
|
+
itemIndex,
|
|
424
|
+
offset,
|
|
425
|
+
bytes,
|
|
426
|
+
totalBytes: exactInteger(result.totalBytes, {
|
|
427
|
+
min: 0,
|
|
428
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
|
|
429
|
+
code: 'clipboard-chunk-total-invalid'
|
|
430
|
+
}),
|
|
431
|
+
final: result.final === true,
|
|
432
|
+
dataBase64: chunk.dataBase64
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
if (action === 'clipboard.paste.chunk') {
|
|
436
|
+
return {
|
|
437
|
+
ok: result.ok !== false,
|
|
438
|
+
action,
|
|
439
|
+
operationId,
|
|
440
|
+
status: safeStatus(result.status) || '',
|
|
441
|
+
itemIndex: exactInteger(result.itemIndex, {
|
|
442
|
+
min: 0,
|
|
443
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxFiles - 1,
|
|
444
|
+
code: 'clipboard-item-index-invalid'
|
|
445
|
+
}),
|
|
446
|
+
offset: exactInteger(result.offset, {
|
|
447
|
+
min: 0,
|
|
448
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
|
|
449
|
+
code: 'clipboard-chunk-offset-invalid'
|
|
450
|
+
}),
|
|
451
|
+
bytes: exactInteger(result.bytes, {
|
|
452
|
+
min: 0,
|
|
453
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxChunkBytes,
|
|
454
|
+
code: 'clipboard-chunk-byte-count-invalid'
|
|
455
|
+
}),
|
|
456
|
+
totalBytes: exactInteger(result.totalBytes, {
|
|
457
|
+
min: 0,
|
|
458
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
|
|
459
|
+
code: 'clipboard-chunk-total-invalid'
|
|
460
|
+
}),
|
|
461
|
+
final: result.final === true
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
if (result.manifest) {
|
|
465
|
+
response.manifest = normalizeRemoteClipboardManifest(result.manifest, operationId);
|
|
466
|
+
response.kind = response.manifest.contentKind;
|
|
467
|
+
} else {
|
|
468
|
+
const kind = result.contentKind || result.kind;
|
|
469
|
+
if (kind) response.kind = normalizeContentKind(kind);
|
|
470
|
+
}
|
|
471
|
+
for (const key of ['receivedBytes', 'totalBytes']) {
|
|
472
|
+
if (result[key] !== undefined) {
|
|
473
|
+
response[key] = exactInteger(result[key], {
|
|
474
|
+
min: 0,
|
|
475
|
+
max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
|
|
476
|
+
code: `clipboard-${key}-invalid`
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (result.ready !== undefined) response.ready = result.ready === true;
|
|
481
|
+
return response;
|
|
482
|
+
}
|