@playcraft/cli 0.1.12 → 0.1.13
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.
|
@@ -2,6 +2,7 @@ import { connect as connectTcp } from 'node:net';
|
|
|
2
2
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
3
3
|
import { desktopHealthSchema, desktopResourceName, runtimeError, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
4
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
5
|
+
import { createRfbWatchGate } from './rfb-watch-gate.js';
|
|
5
6
|
/**
|
|
6
7
|
* A takeover holds the desktop for this long, whether or not a page is watching, and ends on its
|
|
7
8
|
* own unless the user renews it.
|
|
@@ -190,6 +191,7 @@ export class DesktopHost {
|
|
|
190
191
|
}
|
|
191
192
|
else if (this.upstream) {
|
|
192
193
|
const upstream = new WebSocket(`ws://127.0.0.1:${this.upstream.port}/`);
|
|
194
|
+
const watch = createRfbWatchGate();
|
|
193
195
|
viewer.upstream = upstream;
|
|
194
196
|
viewer.detach = () => upstream.close();
|
|
195
197
|
upstream.on('error', () => {
|
|
@@ -204,12 +206,34 @@ export class DesktopHost {
|
|
|
204
206
|
return;
|
|
205
207
|
}
|
|
206
208
|
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
209
|
+
watch.observeServer(chunk);
|
|
210
|
+
if (watch.failed) {
|
|
211
|
+
ws.close(1008, 'desktop_protocol_error');
|
|
212
|
+
upstream.close();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
207
215
|
send(chunk, isBinary);
|
|
208
216
|
});
|
|
209
217
|
upstream.on('close', () => {
|
|
210
218
|
if (ws.readyState === WebSocket.OPEN)
|
|
211
219
|
ws.close();
|
|
212
220
|
});
|
|
221
|
+
ws.on('message', (data, isBinary) => {
|
|
222
|
+
if (this.generation !== generation)
|
|
223
|
+
return;
|
|
224
|
+
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
225
|
+
if (viewer.upstream?.readyState !== WebSocket.OPEN)
|
|
226
|
+
return;
|
|
227
|
+
const outgoing = watch.clientBytes(chunk, this.holder === 'human');
|
|
228
|
+
if (watch.failed) {
|
|
229
|
+
ws.close(1008, 'desktop_protocol_error');
|
|
230
|
+
upstream.close();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
for (const message of outgoing) {
|
|
234
|
+
viewer.upstream.send(message, { binary: isBinary });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
213
237
|
}
|
|
214
238
|
this.viewers.add(viewer);
|
|
215
239
|
let closed = false;
|
|
@@ -223,12 +247,6 @@ export class DesktopHost {
|
|
|
223
247
|
};
|
|
224
248
|
ws.on('close', close);
|
|
225
249
|
ws.on('error', close);
|
|
226
|
-
ws.on('message', (data, isBinary) => {
|
|
227
|
-
if (this.holder !== 'human' || this.generation !== generation)
|
|
228
|
-
return;
|
|
229
|
-
if (viewer.upstream?.readyState === WebSocket.OPEN)
|
|
230
|
-
viewer.upstream.send(data, { binary: isBinary });
|
|
231
|
-
});
|
|
232
250
|
}
|
|
233
251
|
rejectPlainConnect(response, url) {
|
|
234
252
|
if (this.currentStatus() !== 'ready') {
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFB (Remote Framebuffer, 远程帧缓冲) viewing needs a handshake and picture requests.
|
|
3
|
+
* This gate supports the sandbox's version 3.8 / None security negotiation.
|
|
4
|
+
* Input requires a hold for the entire message; unsupported framing closes the stream.
|
|
5
|
+
*/
|
|
6
|
+
const INPUT_TYPES = new Set([4, 5, 6, 250, 251, 255]);
|
|
7
|
+
const VERSION = Buffer.from('RFB 003.008\n');
|
|
8
|
+
const MAX_CLIPBOARD_BYTES = 1_000_000;
|
|
9
|
+
export function createRfbWatchGate() {
|
|
10
|
+
let server = Buffer.alloc(0);
|
|
11
|
+
let client = Buffer.alloc(0);
|
|
12
|
+
let phase = 'server-version';
|
|
13
|
+
let messageInputAllowed = false;
|
|
14
|
+
const fail = () => {
|
|
15
|
+
phase = 'failed';
|
|
16
|
+
server = Buffer.alloc(0);
|
|
17
|
+
client = Buffer.alloc(0);
|
|
18
|
+
};
|
|
19
|
+
const observeServer = (chunk) => {
|
|
20
|
+
if (phase === 'ready' || phase === 'failed')
|
|
21
|
+
return;
|
|
22
|
+
let offset = 0;
|
|
23
|
+
while (offset < chunk.length) {
|
|
24
|
+
let size = serverMessageSize(server, phase);
|
|
25
|
+
if (size === 'invalid') {
|
|
26
|
+
fail();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const count = Math.min(size - server.length, chunk.length - offset);
|
|
30
|
+
server = Buffer.concat([server, chunk.subarray(offset, offset + count)]);
|
|
31
|
+
offset += count;
|
|
32
|
+
size = serverMessageSize(server, phase);
|
|
33
|
+
if (size === 'invalid') {
|
|
34
|
+
fail();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (server.length < size)
|
|
38
|
+
continue;
|
|
39
|
+
if (phase === 'server-version' && server.equals(VERSION))
|
|
40
|
+
phase = 'client-version';
|
|
41
|
+
else if (phase === 'server-types' && server.subarray(1).includes(1))
|
|
42
|
+
phase = 'client-security';
|
|
43
|
+
else if (phase === 'server-result' && server.readUInt32BE(0) === 0)
|
|
44
|
+
phase = 'client-init';
|
|
45
|
+
else if (phase === 'server-init')
|
|
46
|
+
phase = 'ready';
|
|
47
|
+
else {
|
|
48
|
+
fail();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
server = Buffer.alloc(0);
|
|
52
|
+
// The first picture can share the ServerInit chunk; it needs no buffering.
|
|
53
|
+
if (phase === 'ready')
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const clientBytes = (chunk, allowInput) => {
|
|
58
|
+
if (phase === 'failed')
|
|
59
|
+
return [];
|
|
60
|
+
const outgoing = [];
|
|
61
|
+
let offset = 0;
|
|
62
|
+
if (client.length > 0)
|
|
63
|
+
messageInputAllowed &&= allowInput;
|
|
64
|
+
while (offset < chunk.length) {
|
|
65
|
+
if (client.length === 0)
|
|
66
|
+
messageInputAllowed = allowInput;
|
|
67
|
+
let size = phase === 'client-version' ? 12
|
|
68
|
+
: phase === 'client-security' || phase === 'client-init' ? 1
|
|
69
|
+
: phase === 'server-init' || phase === 'ready' ? clientMessageSize(client)
|
|
70
|
+
: 'invalid';
|
|
71
|
+
if (size === 'invalid') {
|
|
72
|
+
fail();
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const count = Math.min(size - client.length, chunk.length - offset);
|
|
76
|
+
client = Buffer.concat([client, chunk.subarray(offset, offset + count)]);
|
|
77
|
+
offset += count;
|
|
78
|
+
if (phase === 'server-init' || phase === 'ready')
|
|
79
|
+
size = clientMessageSize(client);
|
|
80
|
+
if (size === 'invalid') {
|
|
81
|
+
fail();
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
if (client.length < size)
|
|
85
|
+
continue;
|
|
86
|
+
if (phase === 'client-version' && client.equals(VERSION))
|
|
87
|
+
phase = 'server-types';
|
|
88
|
+
else if (phase === 'client-security' && client[0] === 1)
|
|
89
|
+
phase = 'server-result';
|
|
90
|
+
else if (phase === 'client-init' && client[0] <= 1)
|
|
91
|
+
phase = 'server-init';
|
|
92
|
+
else if (phase !== 'server-init' && phase !== 'ready') {
|
|
93
|
+
fail();
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
else if (INPUT_TYPES.has(client[0]) && !messageInputAllowed) {
|
|
97
|
+
client = Buffer.alloc(0);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
outgoing.push(client);
|
|
101
|
+
client = Buffer.alloc(0);
|
|
102
|
+
}
|
|
103
|
+
return outgoing;
|
|
104
|
+
};
|
|
105
|
+
return { get failed() { return phase === 'failed'; }, observeServer, clientBytes };
|
|
106
|
+
}
|
|
107
|
+
function serverMessageSize(buffer, phase) {
|
|
108
|
+
if (phase === 'server-version')
|
|
109
|
+
return 12;
|
|
110
|
+
if (phase === 'server-types') {
|
|
111
|
+
if (buffer.length < 1)
|
|
112
|
+
return 1;
|
|
113
|
+
return buffer[0] === 0 ? 'invalid' : 1 + buffer[0];
|
|
114
|
+
}
|
|
115
|
+
if (phase === 'server-result')
|
|
116
|
+
return 4;
|
|
117
|
+
if (phase === 'server-init') {
|
|
118
|
+
if (buffer.length < 24)
|
|
119
|
+
return 24;
|
|
120
|
+
const length = buffer.readUInt32BE(20);
|
|
121
|
+
return length > 4096 ? 'invalid' : 24 + length;
|
|
122
|
+
}
|
|
123
|
+
return 'invalid';
|
|
124
|
+
}
|
|
125
|
+
function clientMessageSize(buffer) {
|
|
126
|
+
if (buffer.length === 0)
|
|
127
|
+
return 1;
|
|
128
|
+
const type = buffer[0];
|
|
129
|
+
if (type === 0)
|
|
130
|
+
return 20;
|
|
131
|
+
if (type === 3 || type === 150)
|
|
132
|
+
return 10;
|
|
133
|
+
if (type === 4)
|
|
134
|
+
return 8;
|
|
135
|
+
if (type === 5)
|
|
136
|
+
return 6;
|
|
137
|
+
if (type === 250)
|
|
138
|
+
return 4;
|
|
139
|
+
if (type === 2) {
|
|
140
|
+
if (buffer.length < 4)
|
|
141
|
+
return 4;
|
|
142
|
+
const count = buffer.readUInt16BE(2);
|
|
143
|
+
return count > 64 ? 'invalid' : 4 + count * 4;
|
|
144
|
+
}
|
|
145
|
+
if (type === 6) {
|
|
146
|
+
if (buffer.length < 8)
|
|
147
|
+
return 8;
|
|
148
|
+
// Extended clipboard messages encode their payload size as a negative length.
|
|
149
|
+
const length = Math.abs(buffer.readInt32BE(4));
|
|
150
|
+
return length > MAX_CLIPBOARD_BYTES ? 'invalid' : 8 + length;
|
|
151
|
+
}
|
|
152
|
+
if (type === 248) {
|
|
153
|
+
if (buffer.length < 9)
|
|
154
|
+
return 9;
|
|
155
|
+
return buffer[8] > 64 ? 'invalid' : 9 + buffer[8];
|
|
156
|
+
}
|
|
157
|
+
if (type === 251) {
|
|
158
|
+
if (buffer.length < 8)
|
|
159
|
+
return 8;
|
|
160
|
+
return buffer[6] > 16 ? 'invalid' : 8 + buffer[6] * 16;
|
|
161
|
+
}
|
|
162
|
+
if (type === 255) {
|
|
163
|
+
if (buffer.length < 2)
|
|
164
|
+
return 2;
|
|
165
|
+
return buffer[1] === 0 ? 12 : 'invalid';
|
|
166
|
+
}
|
|
167
|
+
return 'invalid';
|
|
168
|
+
}
|
|
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, sameWorkspaceRegistration, workspacePreparationSchema, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { WorkspaceContextError } from './context-error.js';
|
|
7
7
|
import { scaffoldEmptyWorkspace } from './empty-template.js';
|
|
8
8
|
/** The caller holds the Workspace lock. Only an absent, never-published directory may be prepared. */
|
|
@@ -29,7 +29,11 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
29
29
|
throw new WorkspaceContextError('workspace_recovery_required');
|
|
30
30
|
if (await exists(target))
|
|
31
31
|
throw new WorkspaceContextError('workspace_checkout_conflict');
|
|
32
|
-
|
|
32
|
+
// An unfinished checkout recorded before the fork instructions were known must be cloned again.
|
|
33
|
+
const forkMatches = !previous || samePendingFork(previous.binding, binding);
|
|
34
|
+
if (previous && !forkMatches)
|
|
35
|
+
await rm(join(parent, previous.stagingName), { recursive: true, force: true });
|
|
36
|
+
const receipt = previous && forkMatches ? previous : {
|
|
33
37
|
binding,
|
|
34
38
|
stagingName: `.prepare-${binding.workspaceId}-${randomUUID()}`,
|
|
35
39
|
status: 'cloning',
|
|
@@ -172,11 +176,15 @@ async function exists(path) {
|
|
|
172
176
|
throw error;
|
|
173
177
|
}
|
|
174
178
|
}
|
|
179
|
+
function samePendingFork(left, right) {
|
|
180
|
+
const fork = (value) => value.targetKind === 'standalone' ? undefined : value.pendingFork;
|
|
181
|
+
return JSON.stringify(fork(left) ?? null) === JSON.stringify(fork(right) ?? null);
|
|
182
|
+
}
|
|
175
183
|
async function readReceipt(path, binding) {
|
|
176
184
|
try {
|
|
177
185
|
const value = JSON.parse(await readFile(path, 'utf8'));
|
|
178
186
|
const parsed = workspaceRegistrationSchema.parse(value.binding);
|
|
179
|
-
if (
|
|
187
|
+
if (!sameWorkspaceRegistration(parsed, binding) ||
|
|
180
188
|
!['cloning', 'ready', 'published'].includes(value.status) ||
|
|
181
189
|
!new RegExp(`^\\.prepare-${binding.workspaceId}-[0-9a-f-]{36}$`).test(value.stagingName))
|
|
182
190
|
throw new Error();
|
|
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { lstat, realpath } from 'node:fs/promises';
|
|
3
3
|
import { join, relative } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
|
-
import { isLocalWorkspaceGitAuthMode, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { isLocalWorkspaceGitAuthMode, sameWorkspaceRegistration, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { prepareWorkspaceCheckout } from './prepare.js';
|
|
7
7
|
import { WorkspaceContextError } from './context-error.js';
|
|
8
8
|
import { scheduleDependencyInstall } from './dependencies.js';
|
|
@@ -64,7 +64,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
|
64
64
|
state.acceptedLeaseFence = leaseFence;
|
|
65
65
|
await state.journal.setAcceptedLeaseFence(leaseFence);
|
|
66
66
|
const existing = state.journal.getWorkspace(binding.workspaceId);
|
|
67
|
-
if (existing &&
|
|
67
|
+
if (existing && !sameWorkspaceRegistration(existing, binding))
|
|
68
68
|
throw new WorkspaceContextError('workspace_binding_conflict');
|
|
69
69
|
let root;
|
|
70
70
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"@gltf-transform/core": "^4.3.0",
|
|
49
49
|
"@gltf-transform/extensions": "^4.3.0",
|
|
50
50
|
"@gltf-transform/functions": "^4.3.0",
|
|
51
|
-
"@playcraft/build": "^0.1.
|
|
52
|
-
"@playcraft/common": "^0.1.
|
|
51
|
+
"@playcraft/build": "^0.1.13",
|
|
52
|
+
"@playcraft/common": "^0.1.13",
|
|
53
53
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
54
54
|
"chokidar": "^4.0.3",
|
|
55
55
|
"commander": "^13.1.0",
|