@myagentroam/node 0.13.11 → 0.13.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.
Files changed (41) hide show
  1. package/dist/connection/direct-transfer-service.js +60 -0
  2. package/dist/connection/endpoints.js +16 -0
  3. package/dist/connector.js +298 -10
  4. package/dist/ctags-binary.js +5 -1
  5. package/dist/managed-binaries.js +8 -0
  6. package/dist/marctl/package.json +1 -1
  7. package/dist/remote-desktop/desktop-byte-stream.js +128 -0
  8. package/dist/remote-desktop/desktop-error.js +35 -0
  9. package/dist/remote-desktop/desktop-service-core.js +384 -0
  10. package/dist/remote-desktop/direct-desktop-service.js +46 -0
  11. package/dist/remote-desktop/linux-managed-binaries.js +46 -0
  12. package/dist/remote-desktop/macos-managed-binaries.js +19 -0
  13. package/dist/remote-desktop/macos-remote-desktop-runtime.js +121 -0
  14. package/dist/remote-desktop/macvnc-managed-process.js +196 -0
  15. package/dist/remote-desktop/pooled-desktop-attachment.js +62 -0
  16. package/dist/remote-desktop/relay-desktop-service.js +40 -0
  17. package/dist/remote-desktop/remote-desktop-attachment.js +87 -0
  18. package/dist/remote-desktop/rfb-backend-handshake.js +98 -0
  19. package/dist/remote-desktop/rfb-client-gate.js +318 -0
  20. package/dist/remote-desktop/rfb-frontend-handshake.js +38 -0
  21. package/dist/remote-desktop/rfb-handshake-io.js +91 -0
  22. package/dist/remote-desktop/rfb-input-release.js +77 -0
  23. package/dist/remote-desktop/standard-rfb-attachment.js +40 -0
  24. package/dist/remote-desktop/tigervnc-attachment.js +68 -0
  25. package/dist/remote-desktop/tigervnc-backend-pool.js +88 -0
  26. package/dist/remote-desktop/tigervnc-cleanup-channel.js +129 -0
  27. package/dist/remote-desktop/tigervnc-managed-process.js +274 -0
  28. package/dist/remote-desktop/tigervnc-rfb-session.js +190 -0
  29. package/dist/remote-desktop/ultravnc-config.js +35 -0
  30. package/dist/remote-desktop/ultravnc-managed-process.js +180 -0
  31. package/dist/remote-desktop/vnc-password.js +15 -0
  32. package/dist/remote-desktop/windows-managed-binaries.js +18 -0
  33. package/dist/remote-desktop/windows-remote-desktop-runtime.js +151 -0
  34. package/dist/remote-desktop/x11-desktop-topology.js +82 -0
  35. package/dist/remote-desktop/x11-remote-desktop-runtime.js +99 -0
  36. package/dist/remote-desktop/x11-topology-source.js +205 -0
  37. package/dist/remote-desktop/x11-topology.js +88 -0
  38. package/dist/ripgrep-binary.js +5 -1
  39. package/dist/service/node-control-channel.js +7 -1
  40. package/dist/service/node-control-message-service.js +11 -0
  41. package/package.json +7 -6
@@ -0,0 +1,121 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { promisify } from 'node:util';
4
+ import { remoteDesktopDescriptionSchema } from '@myagentroam/protocol';
5
+ import { RemoteDesktopError } from './desktop-error.js';
6
+ import { PooledDesktopAttachment } from './pooled-desktop-attachment.js';
7
+ import { RemoteDesktopAttachment } from './remote-desktop-attachment.js';
8
+ import { TigerVncBackendPool } from './tigervnc-backend-pool.js';
9
+ import { MacVncManagedProcess } from './macvnc-managed-process.js';
10
+ const run = promisify(execFile);
11
+ /** Maps native display bounds to macVNC pixels without rounding the display scale. */
12
+ export function macosCombinedDesktopLayout(screens, generation, revision) {
13
+ if (screens.length === 0)
14
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
15
+ const left = Math.min(...screens.map((screen) => screen.x));
16
+ const top = Math.min(...screens.map((screen) => screen.y));
17
+ const right = Math.max(...screens.map((screen) => screen.x + screen.width));
18
+ const bottom = Math.max(...screens.map((screen) => screen.y + screen.height));
19
+ const scaleX = Math.max(...screens.map((screen) => screen.pixelWidth / screen.width));
20
+ const scaleY = Math.max(...screens.map((screen) => screen.pixelHeight / screen.height));
21
+ const result = remoteDesktopDescriptionSchema.safeParse({
22
+ desktopGeneration: generation,
23
+ topologyRevision: revision,
24
+ framebuffer: {
25
+ width: Math.round((right - left) * scaleX),
26
+ height: Math.round((bottom - top) * scaleY)
27
+ },
28
+ displays: screens.map((screen) => ({
29
+ displayId: `display_${screen.id}`,
30
+ name: `Display ${screen.id}`,
31
+ primary: screen.primary,
32
+ nativeBounds: { x: screen.x, y: screen.y, width: screen.width, height: screen.height },
33
+ framebufferBounds: {
34
+ x: Math.round((screen.x - left) * scaleX),
35
+ y: Math.round((screen.y - top) * scaleY),
36
+ width: Math.round((screen.x + screen.width - left) * scaleX) -
37
+ Math.round((screen.x - left) * scaleX),
38
+ height: Math.round((screen.y + screen.height - top) * scaleY) -
39
+ Math.round((screen.y - top) * scaleY)
40
+ },
41
+ nativeToFramebuffer: [scaleX, 0, 0, scaleY, -left * scaleX || 0, -top * scaleY || 0]
42
+ }))
43
+ });
44
+ if (!result.success)
45
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
46
+ return result.data;
47
+ }
48
+ async function queryScreens(binary, signal) {
49
+ const { stdout } = await run(binary, [], { encoding: 'utf8', maxBuffer: 64 * 1024, signal });
50
+ const parsed = JSON.parse(stdout);
51
+ if (!parsed ||
52
+ typeof parsed !== 'object' ||
53
+ !('displays' in parsed) ||
54
+ !Array.isArray(parsed.displays))
55
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
56
+ return parsed.displays;
57
+ }
58
+ /** Owns the current interactive macOS desktop layout and one shared macVNC backend. */
59
+ export class MacosRemoteDesktopRuntime {
60
+ options;
61
+ #pool;
62
+ #generation = randomUUID();
63
+ #layout;
64
+ #signature = '';
65
+ #revision = 0;
66
+ #disposed = false;
67
+ constructor(options) {
68
+ this.options = options;
69
+ this.#pool = new TigerVncBackendPool((signal, onUnexpectedExit) => MacVncManagedProcess.start({ binaryPath: options.binaryPath, onUnexpectedExit }, signal), () => options.fence('DESKTOP_BACKEND_CLOSED'));
70
+ }
71
+ static async start(options, signal) {
72
+ const runtime = new MacosRemoteDesktopRuntime(options);
73
+ await runtime.refresh(signal);
74
+ return runtime;
75
+ }
76
+ async refresh(signal) {
77
+ const screens = (await queryScreens(this.options.topologyBinary, signal)).sort((left, right) => left.id - right.id);
78
+ const signature = JSON.stringify(screens);
79
+ if (this.#disposed)
80
+ throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
81
+ if (signature === this.#signature)
82
+ return;
83
+ const layout = macosCombinedDesktopLayout(screens.filter((screen) => screen.primary), this.#generation, this.#revision + 1);
84
+ if (layout.framebuffer.width * layout.framebuffer.height > this.options.maxPixels)
85
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
86
+ if (this.#layout)
87
+ this.options.fence('DESKTOP_TOPOLOGY_UNAVAILABLE');
88
+ this.#signature = signature;
89
+ this.#revision++;
90
+ this.#layout = layout;
91
+ }
92
+ describe() {
93
+ if (this.#disposed || !this.#layout || !this.#pool.isAvailable())
94
+ throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
95
+ return structuredClone(this.#layout);
96
+ }
97
+ isCurrent = (command) => !this.#disposed &&
98
+ this.#pool.isAvailable() &&
99
+ !!this.#layout &&
100
+ this.#layout.desktopGeneration === command.desktopGeneration &&
101
+ this.#layout.topologyRevision === command.topologyRevision;
102
+ createAttachment = (command, frontend, callbacks) => new PooledDesktopAttachment(this.#pool, frontend, (backend) => new RemoteDesktopAttachment({
103
+ attachmentId: command.attachmentId,
104
+ frontend,
105
+ backend,
106
+ mode: command.mode,
107
+ maxPixels: this.options.maxPixels,
108
+ isCurrentTopology: (width, height) => this.isCurrent(command) &&
109
+ this.#layout?.framebuffer.width === width &&
110
+ this.#layout.framebuffer.height === height,
111
+ onAttached: callbacks.attached,
112
+ onFailure: callbacks.failed
113
+ }));
114
+ async dispose() {
115
+ if (!this.#disposed) {
116
+ this.#disposed = true;
117
+ this.options.fence('DESKTOP_SESSION_UNAVAILABLE');
118
+ }
119
+ await this.#pool.dispose();
120
+ }
121
+ }
@@ -0,0 +1,196 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { connect, createServer } from 'node:net';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, isAbsolute, join } from 'node:path';
7
+ import { setTimeout as delay } from 'node:timers/promises';
8
+ import { RemoteDesktopError } from './desktop-error.js';
9
+ import { handshakeRfbBackend } from './rfb-backend-handshake.js';
10
+ import { obfuscateVncPassword } from './vnc-password.js';
11
+ /** Owns one macVNC process and its authenticated loopback connections. */
12
+ export class MacVncManagedProcess {
13
+ child;
14
+ directory;
15
+ port;
16
+ secret;
17
+ onUnexpectedExit;
18
+ #connections = new Set();
19
+ #exited;
20
+ #stopping = false;
21
+ #ready = false;
22
+ #didExit = false;
23
+ #stopPromise;
24
+ constructor(child, directory, port, secret, onUnexpectedExit) {
25
+ this.child = child;
26
+ this.directory = directory;
27
+ this.port = port;
28
+ this.secret = secret;
29
+ this.onUnexpectedExit = onUnexpectedExit;
30
+ child.on('error', () => { });
31
+ child.stderr?.resume();
32
+ this.#exited = new Promise((resolve) => {
33
+ child.once('close', () => {
34
+ this.#didExit = true;
35
+ if (this.#ready && !this.#stopping) {
36
+ try {
37
+ this.onUnexpectedExit();
38
+ }
39
+ catch {
40
+ // A notification failure must not prevent process cleanup.
41
+ }
42
+ }
43
+ resolve();
44
+ });
45
+ });
46
+ }
47
+ static async start(options, signal) {
48
+ if (process.platform !== 'darwin' || !isAbsolute(options.binaryPath))
49
+ throw new RemoteDesktopError('DESKTOP_BACKEND_CONFIG_INVALID');
50
+ const deadline = AbortSignal.any([signal, AbortSignal.timeout(10_000)]);
51
+ if (deadline.aborted)
52
+ throw new RemoteDesktopError('DESKTOP_SETUP_CANCELLED');
53
+ const directory = await mkdtemp(join(tmpdir(), 'mar-macvnc-'));
54
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
55
+ const secret = randomBytes(8);
56
+ for (let i = 0; i < secret.length; i++)
57
+ secret[i] = alphabet.charCodeAt(secret[i] & 63);
58
+ let backend;
59
+ try {
60
+ const port = await availableLoopbackPort();
61
+ if (deadline.aborted)
62
+ throw new RemoteDesktopError('DESKTOP_SETUP_CANCELLED');
63
+ const config = join(directory, 'vnc-password');
64
+ const encoded = obfuscateVncPassword(secret);
65
+ try {
66
+ await writeFile(config, encoded, { flag: 'wx', mode: 0o600 });
67
+ }
68
+ finally {
69
+ encoded.fill(0);
70
+ }
71
+ if (deadline.aborted)
72
+ throw new RemoteDesktopError('DESKTOP_SETUP_CANCELLED');
73
+ const child = spawn(options.binaryPath, [
74
+ '-rfbport',
75
+ String(port),
76
+ '-rfbportv6',
77
+ '0',
78
+ '-listen',
79
+ '127.0.0.1',
80
+ '-rfbauth',
81
+ config,
82
+ '-alwaysshared'
83
+ ], {
84
+ cwd: dirname(options.binaryPath),
85
+ windowsHide: true,
86
+ stdio: ['ignore', 'ignore', 'pipe']
87
+ });
88
+ backend = new MacVncManagedProcess(child, directory, port, secret, options.onUnexpectedExit);
89
+ while (!deadline.aborted && !backend.#didExit) {
90
+ try {
91
+ const connection = await backend.connect(64 * 1024 * 1024, deadline);
92
+ connection.socket.destroy();
93
+ if (backend.#didExit || deadline.aborted)
94
+ throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
95
+ backend.#ready = true;
96
+ return backend;
97
+ }
98
+ catch (error) {
99
+ if (deadline.aborted || backend.#didExit)
100
+ throw error;
101
+ await delay(50, undefined, { signal: deadline });
102
+ }
103
+ }
104
+ throw new RemoteDesktopError('DESKTOP_BACKEND_START_FAILED');
105
+ }
106
+ catch (error) {
107
+ if (backend)
108
+ await backend.stop();
109
+ else {
110
+ secret.fill(0);
111
+ await rm(directory, { recursive: true, force: true });
112
+ }
113
+ if (deadline.aborted)
114
+ throw new RemoteDesktopError('DESKTOP_SETUP_CANCELLED');
115
+ if (error instanceof RemoteDesktopError)
116
+ throw error;
117
+ throw new RemoteDesktopError('DESKTOP_BACKEND_START_FAILED');
118
+ }
119
+ }
120
+ async connect(maxPixels, signal) {
121
+ if (this.#stopping || this.#didExit)
122
+ throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
123
+ const socket = connect({ host: '127.0.0.1', port: this.port });
124
+ this.#connections.add(socket);
125
+ socket.once('close', () => this.#connections.delete(socket));
126
+ try {
127
+ await new Promise((resolve, reject) => {
128
+ const cleanup = () => {
129
+ socket.off('connect', connected);
130
+ socket.off('error', failed);
131
+ signal.removeEventListener('abort', aborted);
132
+ };
133
+ const connected = () => {
134
+ cleanup();
135
+ resolve();
136
+ };
137
+ const failed = () => {
138
+ cleanup();
139
+ reject(new RemoteDesktopError('DESKTOP_BACKEND_CLOSED'));
140
+ };
141
+ const aborted = () => {
142
+ cleanup();
143
+ reject(new RemoteDesktopError('DESKTOP_SETUP_CANCELLED'));
144
+ };
145
+ socket.once('connect', connected);
146
+ socket.once('error', failed);
147
+ if (signal.aborted)
148
+ aborted();
149
+ else
150
+ signal.addEventListener('abort', aborted, { once: true });
151
+ });
152
+ const init = await handshakeRfbBackend(socket, this.secret, maxPixels, signal);
153
+ return { socket, init };
154
+ }
155
+ catch (error) {
156
+ socket.on('error', () => { });
157
+ socket.destroy();
158
+ throw error;
159
+ }
160
+ }
161
+ stop() {
162
+ if (this.#stopPromise)
163
+ return this.#stopPromise;
164
+ this.#stopping = true;
165
+ this.#stopPromise = (async () => {
166
+ for (const socket of this.#connections)
167
+ socket.destroy();
168
+ if (!this.#didExit)
169
+ this.child.kill();
170
+ const exited = await Promise.race([
171
+ this.#exited.then(() => true),
172
+ delay(5_000, undefined, { ref: false }).then(() => false)
173
+ ]);
174
+ if (!exited)
175
+ throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
176
+ this.secret.fill(0);
177
+ await rm(this.directory, { recursive: true, force: true });
178
+ })().catch((error) => {
179
+ this.#stopPromise = undefined;
180
+ throw error;
181
+ });
182
+ return this.#stopPromise;
183
+ }
184
+ }
185
+ async function availableLoopbackPort() {
186
+ const listener = createServer();
187
+ return new Promise((resolve, reject) => {
188
+ listener.once('error', reject);
189
+ listener.listen(0, '127.0.0.1', () => {
190
+ const address = listener.address();
191
+ if (!address || typeof address === 'string')
192
+ return listener.close(() => reject(new Error('port')));
193
+ listener.close((error) => (error ? reject(error) : resolve(address.port)));
194
+ });
195
+ });
196
+ }
@@ -0,0 +1,62 @@
1
+ import { RemoteDesktopError } from './desktop-error.js';
2
+ /** Acquires a Remote-owned backend after OPEN and releases it after connection cleanup. */
3
+ export class PooledDesktopAttachment {
4
+ pool;
5
+ frontend;
6
+ create;
7
+ #cancel = new AbortController();
8
+ #reference;
9
+ #attachment;
10
+ #opening;
11
+ #closing;
12
+ #closed = false;
13
+ #started = false;
14
+ constructor(pool, frontend, create) {
15
+ this.pool = pool;
16
+ this.frontend = frontend;
17
+ this.create = create;
18
+ }
19
+ open(signal) {
20
+ if (this.#started || this.#closed)
21
+ return Promise.reject(new RemoteDesktopError('DESKTOP_RFB_CLOSED'));
22
+ this.#started = true;
23
+ const deadline = AbortSignal.any([signal, this.#cancel.signal]);
24
+ this.#opening = (async () => {
25
+ this.#reference = await this.pool.acquire(deadline);
26
+ if (deadline.aborted)
27
+ throw new RemoteDesktopError('DESKTOP_SETUP_CANCELLED');
28
+ this.#attachment = this.create(this.#reference.backend);
29
+ await this.#attachment.open(deadline);
30
+ })();
31
+ return this.#opening;
32
+ }
33
+ close(signal) {
34
+ if (this.#closed)
35
+ return Promise.resolve();
36
+ if (this.#closing)
37
+ return this.#closing;
38
+ this.#cancel.abort();
39
+ // Fence synchronously, even if startup or native cleanup is still pending.
40
+ const closing = this.#attachment?.close(signal).then(() => ({ ok: true }), (error) => ({ ok: false, error }));
41
+ if (!this.#attachment)
42
+ this.frontend.destroy();
43
+ this.#closing = (async () => {
44
+ await this.#opening?.catch(() => { });
45
+ if (closing) {
46
+ const result = await closing;
47
+ if (!result.ok)
48
+ throw result.error;
49
+ }
50
+ else if (this.#attachment)
51
+ await this.#attachment.close(signal);
52
+ if (this.#reference)
53
+ await this.#reference.release();
54
+ this.#reference = undefined;
55
+ this.#attachment = undefined;
56
+ this.#closed = true;
57
+ })().finally(() => {
58
+ this.#closing = undefined;
59
+ });
60
+ return this.#closing;
61
+ }
62
+ }
@@ -0,0 +1,40 @@
1
+ import { RemoteDesktopError } from './desktop-error.js';
2
+ import { DesktopServiceCore } from './desktop-service-core.js';
3
+ /** Binds one Server-authenticated relay socket to the shared attachment lifecycle. */
4
+ export class RelayDesktopService extends DesktopServiceCore {
5
+ constructor(options) {
6
+ super({
7
+ path: 'relay',
8
+ ...(options.registry === undefined ? {} : { registry: options.registry }),
9
+ nodeGeneration: options.nodeGeneration,
10
+ ...(options.now === undefined ? {} : { now: options.now }),
11
+ isCurrent: (command) => command.type === 'desktop.relay.prepare' &&
12
+ command.nodeGeneration === options.nodeGeneration() &&
13
+ options.isCurrent(command),
14
+ createAttachment: (command, stream, callbacks) => {
15
+ if (command.type !== 'desktop.relay.prepare')
16
+ throw new RemoteDesktopError('DESKTOP_TRANSPORT_ERROR');
17
+ return options.createAttachment(command, stream, callbacks);
18
+ },
19
+ emit: (event) => options.emit(event),
20
+ transport: (command) => {
21
+ if (command.type !== 'desktop.relay.prepare')
22
+ return undefined;
23
+ const channel = options.connect();
24
+ return {
25
+ channel,
26
+ valid: () => channel.readyState === 0 || channel.readyState === 1,
27
+ maxMessageBytes: () => 16_384,
28
+ start: () => channel.send(JSON.stringify({
29
+ type: 'desktop.relay.node',
30
+ nodeId: options.nodeId,
31
+ nodeGeneration: command.nodeGeneration,
32
+ attachmentId: command.attachmentId,
33
+ ticket: command.ticket,
34
+ credential: options.credential()
35
+ }))
36
+ };
37
+ }
38
+ });
39
+ }
40
+ }
@@ -0,0 +1,87 @@
1
+ import { RemoteDesktopError } from './desktop-error.js';
2
+ import { TigerVncRfbSession, TigerVncSetupCleanupError } from './tigervnc-rfb-session.js';
3
+ /** Owns one Remote attachment and its cleanup, never the shared backend. */
4
+ export class RemoteDesktopAttachment {
5
+ options;
6
+ #state = 'new';
7
+ #session;
8
+ #pendingCleanup;
9
+ #opening;
10
+ #closing;
11
+ #closeRequested = false;
12
+ #cancel = new AbortController();
13
+ constructor(options) {
14
+ this.options = options;
15
+ if (!options.attachmentId)
16
+ throw new RemoteDesktopError('DESKTOP_BACKEND_CONFIG_INVALID');
17
+ }
18
+ open(signal) {
19
+ if (this.#state !== 'new')
20
+ return Promise.reject(new RemoteDesktopError('DESKTOP_RFB_CLOSED'));
21
+ if (signal.aborted)
22
+ return Promise.reject(new RemoteDesktopError('DESKTOP_SETUP_CANCELLED'));
23
+ this.#state = 'opening';
24
+ this.#opening = this.#start(AbortSignal.any([signal, this.#cancel.signal]));
25
+ return this.#opening;
26
+ }
27
+ close(signal) {
28
+ if (this.#state === 'closed')
29
+ return Promise.resolve();
30
+ if (this.#closing)
31
+ return this.#closing;
32
+ this.#closeRequested = true;
33
+ this.#state = 'cleanup';
34
+ this.#cancel.abort();
35
+ if (this.#session)
36
+ this.#session.fence();
37
+ else
38
+ this.options.frontend.destroy();
39
+ this.#closing = (async () => {
40
+ await this.#opening?.catch(() => { });
41
+ await this.#finishCleanup(signal);
42
+ this.#state = 'closed';
43
+ })().finally(() => {
44
+ this.#closing = undefined;
45
+ });
46
+ return this.#closing;
47
+ }
48
+ async #start(signal) {
49
+ try {
50
+ this.#session = await TigerVncRfbSession.open({
51
+ ...this.options,
52
+ isControlOwner: () => !this.#closeRequested,
53
+ onFailure: (code) => {
54
+ void this.close(AbortSignal.timeout(5_000)).catch(() => { });
55
+ this.options.onFailure(code);
56
+ }
57
+ }, signal);
58
+ if (this.#closeRequested || signal.aborted)
59
+ throw new RemoteDesktopError('DESKTOP_SETUP_CANCELLED');
60
+ this.#state = 'active';
61
+ }
62
+ catch (error) {
63
+ if (error instanceof TigerVncSetupCleanupError) {
64
+ this.#pendingCleanup = error;
65
+ this.#state = 'cleanup';
66
+ throw error;
67
+ }
68
+ try {
69
+ await this.#finishCleanup(AbortSignal.timeout(5_000));
70
+ this.#state = 'closed';
71
+ }
72
+ catch (cleanupError) {
73
+ this.#state = 'cleanup';
74
+ throw cleanupError;
75
+ }
76
+ throw error;
77
+ }
78
+ }
79
+ async #finishCleanup(signal) {
80
+ if (this.#session)
81
+ await this.#session.close(signal);
82
+ if (this.#pendingCleanup)
83
+ await this.#pendingCleanup.retryCleanup(signal);
84
+ this.#session = undefined;
85
+ this.#pendingCleanup = undefined;
86
+ }
87
+ }
@@ -0,0 +1,98 @@
1
+ import { RemoteDesktopError } from './desktop-error.js';
2
+ import { RfbHandshakeIo } from './rfb-handshake-io.js';
3
+ import { createCipheriv } from 'node:crypto';
4
+ /**
5
+ * Temporarily owns a backend stream during RFB 3.8 setup. The caller supplies
6
+ * the setup deadline and, on success, resumes ownership of the paused stream.
7
+ * It never exposes backend credentials or accepts an unauthenticated backend.
8
+ */
9
+ export async function handshakeRfbBackend(stream, password, maxPixels, signal) {
10
+ const io = new RfbHandshakeIo(stream, signal);
11
+ try {
12
+ if (password.length < 1 ||
13
+ password.length > 8 ||
14
+ password.includes(0) ||
15
+ !Number.isSafeInteger(maxPixels) ||
16
+ maxPixels < 1)
17
+ throw new RemoteDesktopError('DESKTOP_RFB_INVALID');
18
+ if (!(await io.read(12)).equals(Buffer.from('RFB 003.008\n')))
19
+ throw new RemoteDesktopError('DESKTOP_RFB_INVALID');
20
+ await io.write(Buffer.from('RFB 003.008\n'));
21
+ const securityCount = (await io.read(1))[0];
22
+ if (securityCount === 0 || !(await io.read(securityCount)).includes(2))
23
+ throw new RemoteDesktopError('DESKTOP_BACKEND_AUTH_UNAVAILABLE');
24
+ await io.write(Buffer.from([2]));
25
+ const challenge = await io.read(16);
26
+ const response = vncResponse(challenge, password);
27
+ try {
28
+ await io.write(response);
29
+ }
30
+ finally {
31
+ response.fill(0);
32
+ challenge.fill(0);
33
+ }
34
+ if ((await io.read(4)).readUInt32BE() !== 0)
35
+ throw new RemoteDesktopError('DESKTOP_BACKEND_AUTH_FAILED');
36
+ // Every attachment is shared, regardless of any frontend request.
37
+ await io.write(Buffer.from([1]));
38
+ const header = await io.read(24);
39
+ const width = header.readUInt16BE(0);
40
+ const height = header.readUInt16BE(2);
41
+ const nameLength = header.readUInt32BE(20);
42
+ if (width === 0 ||
43
+ height === 0 ||
44
+ width * height > maxPixels ||
45
+ nameLength > 4096 ||
46
+ !validPixelFormat(header.subarray(4, 20)))
47
+ throw new RemoteDesktopError('DESKTOP_RFB_INVALID');
48
+ const name = await io.read(nameLength);
49
+ return { width, height, serverInit: Buffer.concat([header, name]) };
50
+ }
51
+ catch (error) {
52
+ stream.destroy();
53
+ if (error instanceof RemoteDesktopError)
54
+ throw error;
55
+ throw new RemoteDesktopError('DESKTOP_TRANSPORT_ERROR');
56
+ }
57
+ finally {
58
+ io.dispose();
59
+ }
60
+ }
61
+ function vncResponse(challenge, password) {
62
+ const key = Buffer.alloc(8);
63
+ for (let i = 0; i < password.length; i++) {
64
+ let byte = password[i];
65
+ byte = ((byte & 0x55) << 1) | ((byte >>> 1) & 0x55);
66
+ byte = ((byte & 0x33) << 2) | ((byte >>> 2) & 0x33);
67
+ key[i] = ((byte & 0x0f) << 4) | (byte >>> 4);
68
+ }
69
+ // EDE with three identical keys is DES ECB without relying on legacy OpenSSL providers.
70
+ const expanded = Buffer.concat([key, key, key]);
71
+ try {
72
+ const cipher = createCipheriv('des-ede3', expanded, null);
73
+ cipher.setAutoPadding(false);
74
+ return Buffer.concat([cipher.update(challenge), cipher.final()]);
75
+ }
76
+ finally {
77
+ key.fill(0);
78
+ expanded.fill(0);
79
+ }
80
+ }
81
+ function validPixelFormat(format) {
82
+ const bpp = format[0];
83
+ const depth = format[1];
84
+ if (![8, 16, 32].includes(bpp) || depth === 0 || depth > bpp || format[3] === 0)
85
+ return false;
86
+ let occupied = 0n;
87
+ for (let i = 0; i < 3; i++) {
88
+ const max = format.readUInt16BE(4 + i * 2);
89
+ const shift = format[10 + i];
90
+ if (max === 0 || (max & (max + 1)) !== 0)
91
+ return false;
92
+ const mask = BigInt(max) << BigInt(shift);
93
+ if (mask >= 1n << BigInt(bpp) || (mask & occupied) !== 0n)
94
+ return false;
95
+ occupied |= mask;
96
+ }
97
+ return true;
98
+ }