@phnx-labs/agents-cli 1.22.102 → 1.22.104
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/CHANGELOG.md +31 -0
- package/README.md +1 -1
- package/dist/bootstrap.js +15 -0
- package/dist/commands/accounts.js +2 -2
- package/dist/commands/computer.d.ts +100 -79
- package/dist/commands/computer.js +290 -830
- package/dist/commands/setup-computer.js +20 -1
- package/dist/commands/setup-secrets.d.ts +2 -2
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/view.js +4 -6
- package/dist/lib/account-catalog.d.ts +22 -1
- package/dist/lib/account-catalog.js +72 -38
- package/dist/lib/accounting/usage.js +18 -14
- package/dist/lib/agent-spec/agents.d.ts +1 -0
- package/dist/lib/agent-spec/agents.js +1 -1
- package/dist/lib/browser/drivers/ssh.js +1 -1
- package/dist/lib/computer/context.d.ts +83 -0
- package/dist/lib/computer/context.js +91 -0
- package/dist/lib/computer/policy.d.ts +46 -0
- package/dist/lib/computer/policy.js +160 -0
- package/dist/lib/computer/record.d.ts +38 -0
- package/dist/lib/computer/record.js +86 -0
- package/dist/lib/computer/sessions-list.js +6 -6
- package/dist/lib/computer-client.d.ts +150 -0
- package/dist/lib/computer-client.js +222 -0
- package/dist/lib/exec.js +35 -1
- package/dist/lib/harness/adapters/claude.d.ts +37 -0
- package/dist/lib/harness/adapters/claude.js +69 -0
- package/dist/lib/helper-download.d.ts +1 -1
- package/dist/lib/helper-download.js +1 -1
- package/dist/lib/helper-versions.d.ts +9 -4
- package/dist/lib/helper-versions.js +9 -9
- package/dist/lib/installations/shims.js +8 -4
- package/dist/lib/menubar/download-menubar.d.ts +2 -1
- package/dist/lib/menubar/download-menubar.js +2 -1
- package/dist/lib/menubar/install-menubar.d.ts +56 -0
- package/dist/lib/menubar/install-menubar.js +133 -17
- package/dist/lib/menubar/resolve-version.d.ts +82 -0
- package/dist/lib/menubar/resolve-version.js +133 -0
- package/dist/lib/profiles.js +13 -2
- package/dist/lib/secrets-client.d.ts +23 -3
- package/dist/lib/secrets-client.js +57 -57
- package/dist/lib/self-heal/checks/menubar-helper.d.ts +2 -0
- package/dist/lib/self-heal/checks/menubar-helper.js +21 -0
- package/dist/lib/self-heal/registry.js +3 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/session/db.js +1 -1
- package/dist/lib/sha256-asset.d.ts +2 -1
- package/dist/lib/sha256-asset.js +2 -1
- package/dist/lib/ssh-tunnel.d.ts +61 -0
- package/dist/lib/ssh-tunnel.js +105 -0
- package/dist/lib/summarizer/summarize.d.ts +2 -2
- package/dist/lib/summarizer/summarize.js +10 -3
- package/package.json +2 -3
- package/dist/commands/computer-actions.d.ts +0 -55
- package/dist/commands/computer-actions.js +0 -594
- package/dist/computer.d.ts +0 -2
- package/dist/computer.js +0 -7
- package/dist/lib/computer/actions.d.ts +0 -36
- package/dist/lib/computer/actions.js +0 -162
- package/dist/lib/computer/computer-rpc.d.ts +0 -39
- package/dist/lib/computer/computer-rpc.js +0 -447
- package/dist/lib/computer/des.d.ts +0 -1
- package/dist/lib/computer/des.js +0 -114
- package/dist/lib/computer/dispatch.d.ts +0 -10
- package/dist/lib/computer/dispatch.js +0 -133
- package/dist/lib/computer/download.d.ts +0 -54
- package/dist/lib/computer/download.js +0 -83
- package/dist/lib/computer/loop.d.ts +0 -62
- package/dist/lib/computer/loop.js +0 -98
- package/dist/lib/computer/model.d.ts +0 -44
- package/dist/lib/computer/model.js +0 -157
- package/dist/lib/computer/rfb-client.d.ts +0 -53
- package/dist/lib/computer/rfb-client.js +0 -562
- package/dist/lib/computer/ssh-tunnel.d.ts +0 -189
- package/dist/lib/computer/ssh-tunnel.js +0 -584
|
@@ -1,562 +0,0 @@
|
|
|
1
|
-
// RFB (VNC) transport for `agents computer` — drive a remote GUI desktop over
|
|
2
|
-
// the RFB protocol instead of a native Accessibility/UIA daemon.
|
|
3
|
-
//
|
|
4
|
-
// This is the third computer backend, sitting behind the same ComputerClient
|
|
5
|
-
// interface as the macOS Unix-socket helper and the Windows TCP helper. It
|
|
6
|
-
// speaks RFB 3.8 directly to an x11vnc/Xvnc server (e.g. a headless Linux
|
|
7
|
-
// desktop, or an LXD container exposing x11vnc on the host's Tailscale IP), so
|
|
8
|
-
// no per-box native helper install is needed — the display is driven over the
|
|
9
|
-
// same VNC stream a human would watch.
|
|
10
|
-
//
|
|
11
|
-
// It is COORDINATE-BASED: RFB carries a framebuffer and pointer/key events, not
|
|
12
|
-
// an accessibility tree. So it implements the observe verb `screenshot` and the
|
|
13
|
-
// interact verbs `click`/`right-click`/`type`/`type-text`/`key`/`scroll`/`wait`;
|
|
14
|
-
// the AX-tree verbs (`describe`/`get-text`/`ax-action`/element-id targeting) and
|
|
15
|
-
// app lifecycle (`launch`/`apps` beyond the synthetic desktop target) have no RFB
|
|
16
|
-
// equivalent and FAIL LOUD with a clear message rather than a silent no-op.
|
|
17
|
-
//
|
|
18
|
-
// Wire format note: unlike the JSON-RPC transports, there is no line-delimited
|
|
19
|
-
// RPC on the wire here — `call(method, params)` translates a computer RPC verb
|
|
20
|
-
// into RFB protocol messages and shapes the reply to match what the command
|
|
21
|
-
// layer expects from the native helpers (e.g. screenshot -> { image_data, width,
|
|
22
|
-
// height }).
|
|
23
|
-
import { createConnection } from 'net';
|
|
24
|
-
import { deflateSync, crc32 } from 'zlib';
|
|
25
|
-
import { desEncryptBlock } from './des.js';
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// Pure protocol helpers (exported for unit tests — no socket needed)
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
// VNC DES auth mangles the key by reversing the bit order of each byte. This is
|
|
30
|
-
// a historical quirk of the reference implementation, not real DES.
|
|
31
|
-
export function mirrorByte(b) {
|
|
32
|
-
let r = 0;
|
|
33
|
-
for (let i = 0; i < 8; i++)
|
|
34
|
-
r |= ((b >> i) & 1) << (7 - i);
|
|
35
|
-
return r & 0xff;
|
|
36
|
-
}
|
|
37
|
-
// Compute the 16-byte VNC-auth response: DES-ECB encrypt the server's 16-byte
|
|
38
|
-
// challenge with the (bit-mirrored, 8-byte, null-padded) password as the key.
|
|
39
|
-
export function vncAuthResponse(challenge, password) {
|
|
40
|
-
if (challenge.length !== 16)
|
|
41
|
-
throw new Error(`vnc challenge must be 16 bytes, got ${challenge.length}`);
|
|
42
|
-
const key = Buffer.alloc(8, 0);
|
|
43
|
-
const pw = Buffer.from(password, 'latin1');
|
|
44
|
-
for (let i = 0; i < 8 && i < pw.length; i++)
|
|
45
|
-
key[i] = mirrorByte(pw[i]);
|
|
46
|
-
// VNC encrypts the two 8-byte halves of the challenge as independent ECB
|
|
47
|
-
// blocks under the same key.
|
|
48
|
-
return Buffer.concat([
|
|
49
|
-
desEncryptBlock(key, challenge.subarray(0, 8)),
|
|
50
|
-
desEncryptBlock(key, challenge.subarray(8, 16)),
|
|
51
|
-
]);
|
|
52
|
-
}
|
|
53
|
-
// 32bpp true-color, little-endian, R@16 G@8 B@0 — so a pixel's 4 wire bytes are
|
|
54
|
-
// [B, G, R, x] and RGB extraction is buf[i+2], buf[i+1], buf[i].
|
|
55
|
-
export function buildPixelFormat() {
|
|
56
|
-
const pf = Buffer.alloc(16, 0);
|
|
57
|
-
pf[0] = 32; // bits-per-pixel
|
|
58
|
-
pf[1] = 24; // depth
|
|
59
|
-
pf[2] = 0; // big-endian-flag (little-endian)
|
|
60
|
-
pf[3] = 1; // true-color-flag
|
|
61
|
-
pf.writeUInt16BE(255, 4); // red-max
|
|
62
|
-
pf.writeUInt16BE(255, 6); // green-max
|
|
63
|
-
pf.writeUInt16BE(255, 8); // blue-max
|
|
64
|
-
pf[10] = 16; // red-shift
|
|
65
|
-
pf[11] = 8; // green-shift
|
|
66
|
-
pf[12] = 0; // blue-shift
|
|
67
|
-
return pf;
|
|
68
|
-
}
|
|
69
|
-
export function encodeSetPixelFormat(pf) {
|
|
70
|
-
const msg = Buffer.alloc(4 + 16);
|
|
71
|
-
msg[0] = 0; // SetPixelFormat
|
|
72
|
-
pf.copy(msg, 4);
|
|
73
|
-
return msg;
|
|
74
|
-
}
|
|
75
|
-
export function encodeSetEncodings(encodings) {
|
|
76
|
-
const msg = Buffer.alloc(4 + encodings.length * 4);
|
|
77
|
-
msg[0] = 2; // SetEncodings
|
|
78
|
-
msg.writeUInt16BE(encodings.length, 2);
|
|
79
|
-
encodings.forEach((e, i) => msg.writeInt32BE(e, 4 + i * 4));
|
|
80
|
-
return msg;
|
|
81
|
-
}
|
|
82
|
-
export function encodeFbUpdateRequest(incremental, x, y, w, h) {
|
|
83
|
-
const msg = Buffer.alloc(10);
|
|
84
|
-
msg[0] = 3; // FramebufferUpdateRequest
|
|
85
|
-
msg[1] = incremental ? 1 : 0;
|
|
86
|
-
msg.writeUInt16BE(x, 2);
|
|
87
|
-
msg.writeUInt16BE(y, 4);
|
|
88
|
-
msg.writeUInt16BE(w, 6);
|
|
89
|
-
msg.writeUInt16BE(h, 8);
|
|
90
|
-
return msg;
|
|
91
|
-
}
|
|
92
|
-
export function encodePointerEvent(buttonMask, x, y) {
|
|
93
|
-
const msg = Buffer.alloc(6);
|
|
94
|
-
msg[0] = 5; // PointerEvent
|
|
95
|
-
msg[1] = buttonMask & 0xff;
|
|
96
|
-
msg.writeUInt16BE(Math.max(0, Math.round(x)), 2);
|
|
97
|
-
msg.writeUInt16BE(Math.max(0, Math.round(y)), 4);
|
|
98
|
-
return msg;
|
|
99
|
-
}
|
|
100
|
-
export function encodeKeyEvent(down, keysym) {
|
|
101
|
-
const msg = Buffer.alloc(8);
|
|
102
|
-
msg[0] = 4; // KeyEvent
|
|
103
|
-
msg[1] = down ? 1 : 0;
|
|
104
|
-
msg.writeUInt32BE(keysym >>> 0, 4);
|
|
105
|
-
return msg;
|
|
106
|
-
}
|
|
107
|
-
// X11 keysyms for the named keys an agent actually presses.
|
|
108
|
-
const NAMED_KEYSYMS = {
|
|
109
|
-
return: 0xff0d, enter: 0xff0d, tab: 0xff09, escape: 0xff1b, esc: 0xff1b,
|
|
110
|
-
backspace: 0xff08, delete: 0xffff, del: 0xffff, space: 0x0020,
|
|
111
|
-
home: 0xff50, end: 0xff57, pageup: 0xff55, pagedown: 0xff56,
|
|
112
|
-
left: 0xff51, up: 0xff52, right: 0xff53, down: 0xff54, insert: 0xff63,
|
|
113
|
-
f1: 0xffbe, f2: 0xffbf, f3: 0xffc0, f4: 0xffc1, f5: 0xffc2, f6: 0xffc3,
|
|
114
|
-
f7: 0xffc4, f8: 0xffc5, f9: 0xffc6, f10: 0xffc7, f11: 0xffc8, f12: 0xffc9,
|
|
115
|
-
};
|
|
116
|
-
const MODIFIER_KEYSYMS = {
|
|
117
|
-
shift: 0xffe1, ctrl: 0xffe3, control: 0xffe3,
|
|
118
|
-
alt: 0xffe9, option: 0xffe9, opt: 0xffe9,
|
|
119
|
-
cmd: 0xffeb, command: 0xffeb, super: 0xffeb, meta: 0xffeb, win: 0xffeb,
|
|
120
|
-
};
|
|
121
|
-
// A single printable character -> its keysym. Latin-1 maps 1:1; anything else
|
|
122
|
-
// uses the Unicode-plane keysym (0x01000000 + codepoint).
|
|
123
|
-
export function keysymForChar(ch) {
|
|
124
|
-
const cp = ch.codePointAt(0) ?? 0;
|
|
125
|
-
if (cp >= 0x20 && cp <= 0xff)
|
|
126
|
-
return cp;
|
|
127
|
-
return 0x01000000 + cp;
|
|
128
|
-
}
|
|
129
|
-
export function keysymForKeyName(name) {
|
|
130
|
-
const k = name.trim().toLowerCase();
|
|
131
|
-
if (k in NAMED_KEYSYMS)
|
|
132
|
-
return NAMED_KEYSYMS[k];
|
|
133
|
-
if ([...name].length === 1)
|
|
134
|
-
return keysymForChar(name);
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
// Parse "ctrl+a" / "cmd+shift+t" / "Return" into modifier keysyms + a key keysym.
|
|
138
|
-
export function parseKeyCombo(combo) {
|
|
139
|
-
const parts = combo.split('+').map((p) => p.trim()).filter(Boolean);
|
|
140
|
-
if (parts.length === 0)
|
|
141
|
-
throw new Error(`empty key combo: "${combo}"`);
|
|
142
|
-
const keyPart = parts[parts.length - 1];
|
|
143
|
-
const modParts = parts.slice(0, -1);
|
|
144
|
-
const modifiers = [];
|
|
145
|
-
for (const m of modParts) {
|
|
146
|
-
const ks = MODIFIER_KEYSYMS[m.toLowerCase()];
|
|
147
|
-
if (ks == null)
|
|
148
|
-
throw new Error(`unknown modifier "${m}" in "${combo}"`);
|
|
149
|
-
modifiers.push(ks);
|
|
150
|
-
}
|
|
151
|
-
const key = keysymForKeyName(keyPart);
|
|
152
|
-
if (key == null)
|
|
153
|
-
throw new Error(`unknown key "${keyPart}" in "${combo}"`);
|
|
154
|
-
return { modifiers, key };
|
|
155
|
-
}
|
|
156
|
-
// Minimal PNG encoder (color-type 2, RGB, 8-bit, no interlace). rgb is
|
|
157
|
-
// width*height*3 bytes, row-major. Uses zlib for IDAT and zlib.crc32 for chunk
|
|
158
|
-
// CRCs — no external dependency, deterministic, unit-testable.
|
|
159
|
-
export function encodePng(rgb, width, height) {
|
|
160
|
-
const stride = width * 3;
|
|
161
|
-
// Prepend a per-scanline filter byte (0 = none).
|
|
162
|
-
const raw = Buffer.alloc(height * (stride + 1));
|
|
163
|
-
for (let y = 0; y < height; y++) {
|
|
164
|
-
raw[y * (stride + 1)] = 0;
|
|
165
|
-
rgb.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
|
|
166
|
-
}
|
|
167
|
-
const idat = deflateSync(raw);
|
|
168
|
-
const chunk = (type, data) => {
|
|
169
|
-
const len = Buffer.alloc(4);
|
|
170
|
-
len.writeUInt32BE(data.length, 0);
|
|
171
|
-
const typeBuf = Buffer.from(type, 'latin1');
|
|
172
|
-
const crc = Buffer.alloc(4);
|
|
173
|
-
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])) >>> 0, 0);
|
|
174
|
-
return Buffer.concat([len, typeBuf, data, crc]);
|
|
175
|
-
};
|
|
176
|
-
const ihdr = Buffer.alloc(13);
|
|
177
|
-
ihdr.writeUInt32BE(width, 0);
|
|
178
|
-
ihdr.writeUInt32BE(height, 4);
|
|
179
|
-
ihdr[8] = 8; // bit depth
|
|
180
|
-
ihdr[9] = 2; // color type RGB
|
|
181
|
-
ihdr[10] = 0; // compression
|
|
182
|
-
ihdr[11] = 0; // filter
|
|
183
|
-
ihdr[12] = 0; // interlace
|
|
184
|
-
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
185
|
-
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
|
|
186
|
-
}
|
|
187
|
-
// Map scroll deltas to VNC wheel buttons. The `--dy` contract is "negative =
|
|
188
|
-
// down" (honored by the macOS CGEvent and Windows MOUSEEVENTF_WHEEL backends by
|
|
189
|
-
// passing dy straight through); `--dx` negative = left. RFB/X11 wheel buttons:
|
|
190
|
-
// 4 = up, 5 = down, 6 = left, 7 = right. Pure + unit-tested so the direction
|
|
191
|
-
// contract can't silently invert (it did, pre-review).
|
|
192
|
-
export function scrollButtonsFor(dx, dy) {
|
|
193
|
-
return {
|
|
194
|
-
vButton: dy < 0 ? 5 : dy > 0 ? 4 : 0,
|
|
195
|
-
hButton: dx < 0 ? 6 : dx > 0 ? 7 : 0,
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
// Guard on any length-prefixed string the server frames (name, failure reason,
|
|
199
|
-
// ServerCutText). A broken/hostile server sending a huge length would otherwise
|
|
200
|
-
// make us wait indefinitely for bytes that never come; fail loud instead.
|
|
201
|
-
export const MAX_RFB_STRING_LEN = 1 << 20; // 1 MiB
|
|
202
|
-
// ---------------------------------------------------------------------------
|
|
203
|
-
// Async socket reader — serve exact-length reads off a streamed connection.
|
|
204
|
-
// ---------------------------------------------------------------------------
|
|
205
|
-
class ByteReader {
|
|
206
|
-
buf = Buffer.alloc(0);
|
|
207
|
-
want = null;
|
|
208
|
-
error = null;
|
|
209
|
-
push(chunk) {
|
|
210
|
-
this.buf = Buffer.concat([this.buf, chunk]);
|
|
211
|
-
this.pump();
|
|
212
|
-
}
|
|
213
|
-
fail(err) {
|
|
214
|
-
this.error = err;
|
|
215
|
-
if (this.want) {
|
|
216
|
-
const w = this.want;
|
|
217
|
-
this.want = null;
|
|
218
|
-
// Reject via a sentinel-length read; the awaiting read() rejects below.
|
|
219
|
-
w.resolve(Buffer.alloc(0));
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
pump() {
|
|
223
|
-
if (this.want && this.buf.length >= this.want.n) {
|
|
224
|
-
const { n, resolve } = this.want;
|
|
225
|
-
this.want = null;
|
|
226
|
-
const out = this.buf.subarray(0, n);
|
|
227
|
-
this.buf = this.buf.subarray(n);
|
|
228
|
-
resolve(out);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
read(n) {
|
|
232
|
-
if (this.error)
|
|
233
|
-
return Promise.reject(this.error);
|
|
234
|
-
return new Promise((resolve, reject) => {
|
|
235
|
-
this.want = {
|
|
236
|
-
n,
|
|
237
|
-
resolve: (b) => {
|
|
238
|
-
if (this.error)
|
|
239
|
-
reject(this.error);
|
|
240
|
-
else
|
|
241
|
-
resolve(b);
|
|
242
|
-
},
|
|
243
|
-
};
|
|
244
|
-
this.pump();
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
export function parseVncEndpoint(raw) {
|
|
249
|
-
if (!raw || raw.length === 0)
|
|
250
|
-
return null;
|
|
251
|
-
const idx = raw.lastIndexOf(':');
|
|
252
|
-
const host = idx >= 0 ? raw.slice(0, idx) : raw;
|
|
253
|
-
const portStr = idx >= 0 ? raw.slice(idx + 1) : '5901';
|
|
254
|
-
const port = Number(portStr);
|
|
255
|
-
if (!Number.isInteger(port) || port <= 0 || port > 65535)
|
|
256
|
-
return null;
|
|
257
|
-
return { host: host || '127.0.0.1', port };
|
|
258
|
-
}
|
|
259
|
-
const RPC_TIMEOUT_MS = 30_000;
|
|
260
|
-
// The one synthetic target the VNC backend exposes: the whole desktop. Lets the
|
|
261
|
-
// screenshot command's list_apps->pickTarget->pid flow resolve without change
|
|
262
|
-
// (`active: true` is what pickTarget's frontmost-app default selects).
|
|
263
|
-
const DESKTOP_APP = { pid: 1, bundle_id: 'desktop', name: 'Desktop', active: true };
|
|
264
|
-
export class RfbClient {
|
|
265
|
-
host;
|
|
266
|
-
port;
|
|
267
|
-
password;
|
|
268
|
-
sock = null;
|
|
269
|
-
reader = new ByteReader();
|
|
270
|
-
ready;
|
|
271
|
-
info = null;
|
|
272
|
-
closed = false;
|
|
273
|
-
constructor(host, port, password) {
|
|
274
|
-
this.host = host;
|
|
275
|
-
this.port = port;
|
|
276
|
-
this.password = password;
|
|
277
|
-
this.ready = this.handshake();
|
|
278
|
-
}
|
|
279
|
-
write(b) {
|
|
280
|
-
if (!this.sock)
|
|
281
|
-
throw new Error('vnc socket not connected');
|
|
282
|
-
this.sock.write(b);
|
|
283
|
-
}
|
|
284
|
-
// Read a U32-length-prefixed latin1 string, capped so a bogus length can't
|
|
285
|
-
// wedge us waiting for bytes that never arrive.
|
|
286
|
-
async readLenString(what) {
|
|
287
|
-
const len = (await this.reader.read(4)).readUInt32BE(0);
|
|
288
|
-
if (len > MAX_RFB_STRING_LEN)
|
|
289
|
-
throw new Error(`vnc ${what} length ${len} exceeds ${MAX_RFB_STRING_LEN}`);
|
|
290
|
-
return (await this.reader.read(len)).toString('latin1');
|
|
291
|
-
}
|
|
292
|
-
async handshake() {
|
|
293
|
-
await new Promise((resolve, reject) => {
|
|
294
|
-
const sock = createConnection({ host: this.host, port: this.port }, () => resolve());
|
|
295
|
-
sock.on('data', (c) => this.reader.push(c));
|
|
296
|
-
sock.on('error', (e) => {
|
|
297
|
-
this.closed = true;
|
|
298
|
-
this.reader.fail(e);
|
|
299
|
-
reject(e);
|
|
300
|
-
});
|
|
301
|
-
sock.on('close', () => {
|
|
302
|
-
this.closed = true;
|
|
303
|
-
this.reader.fail(new Error('vnc connection closed'));
|
|
304
|
-
});
|
|
305
|
-
this.sock = sock;
|
|
306
|
-
});
|
|
307
|
-
// 1. ProtocolVersion: server sends "RFB 003.00x\n", we answer 3.8.
|
|
308
|
-
const serverVersion = (await this.reader.read(12)).toString('latin1');
|
|
309
|
-
if (!serverVersion.startsWith('RFB '))
|
|
310
|
-
throw new Error(`not an RFB server (got "${serverVersion.trim()}")`);
|
|
311
|
-
this.write(Buffer.from('RFB 003.008\n', 'latin1'));
|
|
312
|
-
// 2. Security types (3.7+): count, then that many U8 types.
|
|
313
|
-
const nTypes = (await this.reader.read(1))[0];
|
|
314
|
-
if (nTypes === 0) {
|
|
315
|
-
const reason = await this.readLenString('failure reason');
|
|
316
|
-
throw new Error(`vnc server refused connection: ${reason}`);
|
|
317
|
-
}
|
|
318
|
-
const types = [...(await this.reader.read(nTypes))];
|
|
319
|
-
// Prefer VNC auth (2); accept None (1).
|
|
320
|
-
let chosen;
|
|
321
|
-
if (types.includes(2))
|
|
322
|
-
chosen = 2;
|
|
323
|
-
else if (types.includes(1))
|
|
324
|
-
chosen = 1;
|
|
325
|
-
else
|
|
326
|
-
throw new Error(`no supported vnc security type (server offered ${types.join(',')})`);
|
|
327
|
-
this.write(Buffer.from([chosen]));
|
|
328
|
-
if (chosen === 2) {
|
|
329
|
-
const challenge = await this.reader.read(16);
|
|
330
|
-
if (!this.password)
|
|
331
|
-
throw new Error('vnc server requires a password; set COMPUTER_HELPER_VNC_PASSWORD or --vnc-password');
|
|
332
|
-
const response = vncAuthResponse(challenge, this.password);
|
|
333
|
-
this.write(response);
|
|
334
|
-
}
|
|
335
|
-
// 3. SecurityResult (always present in 3.8).
|
|
336
|
-
const result = (await this.reader.read(4)).readUInt32BE(0);
|
|
337
|
-
if (result !== 0) {
|
|
338
|
-
// 3.8 sends a reason string on failure.
|
|
339
|
-
let reason = 'authentication failed';
|
|
340
|
-
try {
|
|
341
|
-
reason = await this.readLenString('auth failure reason');
|
|
342
|
-
}
|
|
343
|
-
catch { /* some servers just drop the connection instead */ }
|
|
344
|
-
throw new Error(`vnc auth failed: ${reason}`);
|
|
345
|
-
}
|
|
346
|
-
// 4. ClientInit (shared = 1) -> ServerInit.
|
|
347
|
-
this.write(Buffer.from([1]));
|
|
348
|
-
const head = await this.reader.read(4 + 16 + 4);
|
|
349
|
-
const width = head.readUInt16BE(0);
|
|
350
|
-
const height = head.readUInt16BE(2);
|
|
351
|
-
const nameLen = head.readUInt32BE(20);
|
|
352
|
-
if (nameLen > MAX_RFB_STRING_LEN)
|
|
353
|
-
throw new Error(`vnc desktop name length ${nameLen} exceeds ${MAX_RFB_STRING_LEN}`);
|
|
354
|
-
const name = (await this.reader.read(nameLen)).toString('latin1');
|
|
355
|
-
// Pin our pixel format + advertise only Raw so decode is simple + correct.
|
|
356
|
-
this.write(encodeSetPixelFormat(buildPixelFormat()));
|
|
357
|
-
this.write(encodeSetEncodings([0]));
|
|
358
|
-
this.info = { width, height, name };
|
|
359
|
-
return this.info;
|
|
360
|
-
}
|
|
361
|
-
// Tear the connection down for good: reject any pending read, destroy the
|
|
362
|
-
// socket, and mark closed so every later call fails loud. Idempotent.
|
|
363
|
-
teardown(message) {
|
|
364
|
-
if (this.closed)
|
|
365
|
-
return;
|
|
366
|
-
this.closed = true;
|
|
367
|
-
this.reader.fail(new Error(message));
|
|
368
|
-
this.sock?.destroy();
|
|
369
|
-
}
|
|
370
|
-
// A timed-out RFB read cannot be recovered: the awaited bytes may still land
|
|
371
|
-
// later and would be misread as the NEXT request's reply, permanently
|
|
372
|
-
// desyncing the stream. So a timeout tears the whole connection down rather
|
|
373
|
-
// than leaving a pending read for the next call to silently overwrite.
|
|
374
|
-
withTimeout(p, label) {
|
|
375
|
-
return new Promise((resolve, reject) => {
|
|
376
|
-
const timer = setTimeout(() => {
|
|
377
|
-
const msg = `vnc ${label} timed out after ${RPC_TIMEOUT_MS}ms`;
|
|
378
|
-
this.teardown(msg);
|
|
379
|
-
reject(new Error(msg));
|
|
380
|
-
}, RPC_TIMEOUT_MS);
|
|
381
|
-
p.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
// Request the full framebuffer and assemble it into an RGB buffer, then PNG.
|
|
385
|
-
async captureScreen() {
|
|
386
|
-
const info = this.info;
|
|
387
|
-
this.write(encodeFbUpdateRequest(false, 0, 0, info.width, info.height));
|
|
388
|
-
const rgb = Buffer.alloc(info.width * info.height * 3);
|
|
389
|
-
// Read messages until a FramebufferUpdate covering the frame is assembled.
|
|
390
|
-
// We requested a single non-incremental full-frame update, so one
|
|
391
|
-
// FramebufferUpdate message carries all rectangles.
|
|
392
|
-
for (;;) {
|
|
393
|
-
const msgType = (await this.reader.read(1))[0];
|
|
394
|
-
if (msgType === 0) {
|
|
395
|
-
await this.reader.read(1); // padding
|
|
396
|
-
const nRects = (await this.reader.read(2)).readUInt16BE(0);
|
|
397
|
-
for (let r = 0; r < nRects; r++) {
|
|
398
|
-
const rh = await this.reader.read(12);
|
|
399
|
-
const rx = rh.readUInt16BE(0);
|
|
400
|
-
const ry = rh.readUInt16BE(2);
|
|
401
|
-
const rw = rh.readUInt16BE(4);
|
|
402
|
-
const rHt = rh.readUInt16BE(6);
|
|
403
|
-
const enc = rh.readInt32BE(8);
|
|
404
|
-
if (enc !== 0)
|
|
405
|
-
throw new Error(`vnc server used unsupported encoding ${enc} (only Raw was advertised)`);
|
|
406
|
-
const pixels = await this.reader.read(rw * rHt * 4);
|
|
407
|
-
for (let y = 0; y < rHt; y++) {
|
|
408
|
-
for (let x = 0; x < rw; x++) {
|
|
409
|
-
const src = (y * rw + x) * 4;
|
|
410
|
-
const dstX = rx + x;
|
|
411
|
-
const dstY = ry + y;
|
|
412
|
-
if (dstX >= info.width || dstY >= info.height)
|
|
413
|
-
continue;
|
|
414
|
-
const dst = (dstY * info.width + dstX) * 3;
|
|
415
|
-
rgb[dst] = pixels[src + 2]; // R
|
|
416
|
-
rgb[dst + 1] = pixels[src + 1]; // G
|
|
417
|
-
rgb[dst + 2] = pixels[src]; // B
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
break;
|
|
422
|
-
}
|
|
423
|
-
else if (msgType === 2) {
|
|
424
|
-
// Bell — no body.
|
|
425
|
-
}
|
|
426
|
-
else if (msgType === 3) {
|
|
427
|
-
// ServerCutText: 3 padding + U32 length + text (discarded).
|
|
428
|
-
await this.reader.read(3);
|
|
429
|
-
await this.readLenString('server cut text');
|
|
430
|
-
}
|
|
431
|
-
else {
|
|
432
|
-
throw new Error(`unexpected vnc server message type ${msgType}`);
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
return { image_data: encodePng(rgb, info.width, info.height).toString('base64'), width: info.width, height: info.height };
|
|
436
|
-
}
|
|
437
|
-
sendClick(x, y, button, count) {
|
|
438
|
-
const mask = 1 << (button - 1);
|
|
439
|
-
for (let i = 0; i < Math.max(1, count); i++) {
|
|
440
|
-
this.write(encodePointerEvent(0, x, y)); // move
|
|
441
|
-
this.write(encodePointerEvent(mask, x, y)); // press
|
|
442
|
-
this.write(encodePointerEvent(0, x, y)); // release
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
sendScroll(x, y, dx, dy, count) {
|
|
446
|
-
const notches = Math.max(1, count || Math.max(Math.abs(dx), Math.abs(dy)) || 1);
|
|
447
|
-
const { vButton, hButton } = scrollButtonsFor(dx, dy);
|
|
448
|
-
for (const button of [vButton, hButton]) {
|
|
449
|
-
if (!button)
|
|
450
|
-
continue;
|
|
451
|
-
const mask = 1 << (button - 1);
|
|
452
|
-
for (let i = 0; i < notches; i++) {
|
|
453
|
-
this.write(encodePointerEvent(mask, x, y));
|
|
454
|
-
this.write(encodePointerEvent(0, x, y));
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
typeText(text) {
|
|
459
|
-
for (const ch of text) {
|
|
460
|
-
const ks = keysymForChar(ch);
|
|
461
|
-
this.write(encodeKeyEvent(true, ks));
|
|
462
|
-
this.write(encodeKeyEvent(false, ks));
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
pressCombo(combo) {
|
|
466
|
-
const { modifiers, key } = parseKeyCombo(combo);
|
|
467
|
-
for (const m of modifiers)
|
|
468
|
-
this.write(encodeKeyEvent(true, m));
|
|
469
|
-
this.write(encodeKeyEvent(true, key));
|
|
470
|
-
this.write(encodeKeyEvent(false, key));
|
|
471
|
-
for (const m of [...modifiers].reverse())
|
|
472
|
-
this.write(encodeKeyEvent(false, m));
|
|
473
|
-
}
|
|
474
|
-
async call(method, params = {}) {
|
|
475
|
-
if (this.closed)
|
|
476
|
-
return { id: null, error: { code: 'vnc_closed', message: 'vnc connection closed' } };
|
|
477
|
-
try {
|
|
478
|
-
await this.withTimeout(this.ready, 'handshake');
|
|
479
|
-
const x = Number(params.x ?? 0);
|
|
480
|
-
const y = Number(params.y ?? 0);
|
|
481
|
-
switch (method) {
|
|
482
|
-
case 'list_apps':
|
|
483
|
-
return { id: null, result: { apps: [DESKTOP_APP] } };
|
|
484
|
-
case 'screenshot': {
|
|
485
|
-
if (params.list)
|
|
486
|
-
return { id: null, result: { windows: [] } };
|
|
487
|
-
const shot = await this.withTimeout(this.captureScreen(), 'screenshot');
|
|
488
|
-
return { id: null, result: shot };
|
|
489
|
-
}
|
|
490
|
-
case 'click':
|
|
491
|
-
this.sendClick(x, y, 1, Number(params.count ?? 1));
|
|
492
|
-
return { id: null, result: { ok: true } };
|
|
493
|
-
case 'right_click':
|
|
494
|
-
this.sendClick(x, y, 3, Number(params.count ?? 1));
|
|
495
|
-
return { id: null, result: { ok: true } };
|
|
496
|
-
case 'scroll':
|
|
497
|
-
this.sendScroll(x, y, Number(params.dx ?? 0), Number(params.dy ?? 0), Number(params.count ?? 0));
|
|
498
|
-
return { id: null, result: { ok: true } };
|
|
499
|
-
case 'type': {
|
|
500
|
-
if (params.x != null && params.y != null)
|
|
501
|
-
this.sendClick(x, y, 1, 1);
|
|
502
|
-
if (params.text != null)
|
|
503
|
-
this.typeText(String(params.text));
|
|
504
|
-
if (params.commit)
|
|
505
|
-
this.pressCombo('Return');
|
|
506
|
-
return { id: null, result: { ok: true } };
|
|
507
|
-
}
|
|
508
|
-
case 'type_text':
|
|
509
|
-
if (params.text != null)
|
|
510
|
-
this.typeText(String(params.text));
|
|
511
|
-
if (params.commit)
|
|
512
|
-
this.pressCombo('Return');
|
|
513
|
-
return { id: null, result: { ok: true } };
|
|
514
|
-
case 'key':
|
|
515
|
-
for (const combo of String(params.keys ?? '').split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
516
|
-
this.pressCombo(combo);
|
|
517
|
-
}
|
|
518
|
-
return { id: null, result: { ok: true } };
|
|
519
|
-
case 'focus_window':
|
|
520
|
-
case 'set_focus':
|
|
521
|
-
// No window manager control over RFB; the desktop is the target.
|
|
522
|
-
return { id: null, result: { ok: true } };
|
|
523
|
-
case 'wait': {
|
|
524
|
-
const ms = Number(params.duration_ms ?? 0);
|
|
525
|
-
if (ms > 0)
|
|
526
|
-
await new Promise((r) => setTimeout(r, Math.min(ms, RPC_TIMEOUT_MS)));
|
|
527
|
-
return { id: null, result: { ok: true } };
|
|
528
|
-
}
|
|
529
|
-
case 'describe':
|
|
530
|
-
case 'get_text':
|
|
531
|
-
case 'ax_action':
|
|
532
|
-
return { id: null, error: { code: 'unsupported_over_vnc', message: `'${method}' needs an accessibility tree; the VNC backend is coordinate-based. Use screenshot + click/type by coordinate.` } };
|
|
533
|
-
case 'launch_app':
|
|
534
|
-
return { id: null, error: { code: 'unsupported_over_vnc', message: 'launch is not available over VNC; open the app from the desktop UI with click/type.' } };
|
|
535
|
-
default:
|
|
536
|
-
return { id: null, error: { code: 'unknown_method', message: `vnc backend has no method '${method}'` } };
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
catch (e) {
|
|
540
|
-
return { id: null, error: { code: 'vnc_error', message: e.message } };
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
async close() {
|
|
544
|
-
if (this.closed)
|
|
545
|
-
return;
|
|
546
|
-
this.closed = true;
|
|
547
|
-
const sock = this.sock;
|
|
548
|
-
if (!sock)
|
|
549
|
-
return;
|
|
550
|
-
sock.end();
|
|
551
|
-
// Await real teardown (like TcpClient.close), but never hang on it.
|
|
552
|
-
await new Promise((resolve) => {
|
|
553
|
-
let done = false;
|
|
554
|
-
const fin = () => { if (!done) {
|
|
555
|
-
done = true;
|
|
556
|
-
resolve();
|
|
557
|
-
} };
|
|
558
|
-
sock.once('close', fin);
|
|
559
|
-
setTimeout(fin, 2000);
|
|
560
|
-
});
|
|
561
|
-
}
|
|
562
|
-
}
|