@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/dist/bootstrap.js +15 -0
  4. package/dist/commands/accounts.js +2 -2
  5. package/dist/commands/computer.d.ts +100 -79
  6. package/dist/commands/computer.js +290 -830
  7. package/dist/commands/setup-computer.js +20 -1
  8. package/dist/commands/setup-secrets.d.ts +2 -2
  9. package/dist/commands/setup-secrets.js +1 -1
  10. package/dist/commands/view.js +4 -6
  11. package/dist/lib/account-catalog.d.ts +22 -1
  12. package/dist/lib/account-catalog.js +72 -38
  13. package/dist/lib/accounting/usage.js +18 -14
  14. package/dist/lib/agent-spec/agents.d.ts +1 -0
  15. package/dist/lib/agent-spec/agents.js +1 -1
  16. package/dist/lib/browser/drivers/ssh.js +1 -1
  17. package/dist/lib/computer/context.d.ts +83 -0
  18. package/dist/lib/computer/context.js +91 -0
  19. package/dist/lib/computer/policy.d.ts +46 -0
  20. package/dist/lib/computer/policy.js +160 -0
  21. package/dist/lib/computer/record.d.ts +38 -0
  22. package/dist/lib/computer/record.js +86 -0
  23. package/dist/lib/computer/sessions-list.js +6 -6
  24. package/dist/lib/computer-client.d.ts +150 -0
  25. package/dist/lib/computer-client.js +222 -0
  26. package/dist/lib/exec.js +35 -1
  27. package/dist/lib/harness/adapters/claude.d.ts +37 -0
  28. package/dist/lib/harness/adapters/claude.js +69 -0
  29. package/dist/lib/helper-download.d.ts +1 -1
  30. package/dist/lib/helper-download.js +1 -1
  31. package/dist/lib/helper-versions.d.ts +9 -4
  32. package/dist/lib/helper-versions.js +9 -9
  33. package/dist/lib/installations/shims.js +8 -4
  34. package/dist/lib/menubar/download-menubar.d.ts +2 -1
  35. package/dist/lib/menubar/download-menubar.js +2 -1
  36. package/dist/lib/menubar/install-menubar.d.ts +56 -0
  37. package/dist/lib/menubar/install-menubar.js +133 -17
  38. package/dist/lib/menubar/resolve-version.d.ts +82 -0
  39. package/dist/lib/menubar/resolve-version.js +133 -0
  40. package/dist/lib/profiles.js +13 -2
  41. package/dist/lib/secrets-client.d.ts +23 -3
  42. package/dist/lib/secrets-client.js +57 -57
  43. package/dist/lib/self-heal/checks/menubar-helper.d.ts +2 -0
  44. package/dist/lib/self-heal/checks/menubar-helper.js +21 -0
  45. package/dist/lib/self-heal/registry.js +3 -0
  46. package/dist/lib/self-heal/types.d.ts +1 -1
  47. package/dist/lib/session/db.js +1 -1
  48. package/dist/lib/sha256-asset.d.ts +2 -1
  49. package/dist/lib/sha256-asset.js +2 -1
  50. package/dist/lib/ssh-tunnel.d.ts +61 -0
  51. package/dist/lib/ssh-tunnel.js +105 -0
  52. package/dist/lib/summarizer/summarize.d.ts +2 -2
  53. package/dist/lib/summarizer/summarize.js +10 -3
  54. package/package.json +2 -3
  55. package/dist/commands/computer-actions.d.ts +0 -55
  56. package/dist/commands/computer-actions.js +0 -594
  57. package/dist/computer.d.ts +0 -2
  58. package/dist/computer.js +0 -7
  59. package/dist/lib/computer/actions.d.ts +0 -36
  60. package/dist/lib/computer/actions.js +0 -162
  61. package/dist/lib/computer/computer-rpc.d.ts +0 -39
  62. package/dist/lib/computer/computer-rpc.js +0 -447
  63. package/dist/lib/computer/des.d.ts +0 -1
  64. package/dist/lib/computer/des.js +0 -114
  65. package/dist/lib/computer/dispatch.d.ts +0 -10
  66. package/dist/lib/computer/dispatch.js +0 -133
  67. package/dist/lib/computer/download.d.ts +0 -54
  68. package/dist/lib/computer/download.js +0 -83
  69. package/dist/lib/computer/loop.d.ts +0 -62
  70. package/dist/lib/computer/loop.js +0 -98
  71. package/dist/lib/computer/model.d.ts +0 -44
  72. package/dist/lib/computer/model.js +0 -157
  73. package/dist/lib/computer/rfb-client.d.ts +0 -53
  74. package/dist/lib/computer/rfb-client.js +0 -562
  75. package/dist/lib/computer/ssh-tunnel.d.ts +0 -189
  76. 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
- }