@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
- const receipt = previous ?? {
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 (JSON.stringify(parsed) !== JSON.stringify(binding) ||
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 && JSON.stringify(existing) !== JSON.stringify(binding))
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.12",
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.12",
52
- "@playcraft/common": "^0.1.12",
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",