ai-remote 0.1.0 → 0.3.0

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 (47) hide show
  1. package/dist/cli.mjs +8065 -0
  2. package/dist/index.js +6 -6
  3. package/dist/protocols/index.js +2 -2
  4. package/dist/protocols/rdp/caps.js +1 -1
  5. package/dist/protocols/rdp/cert.js +1 -1
  6. package/dist/protocols/rdp/client.js +18 -16
  7. package/dist/protocols/rdp/client.js.map +2 -2
  8. package/dist/protocols/rdp/cliprdr.js +1 -1
  9. package/dist/protocols/rdp/credssp.js +2 -2
  10. package/dist/protocols/rdp/display.js +2 -2
  11. package/dist/protocols/rdp/gcc.js +2 -2
  12. package/dist/protocols/rdp/mcs.js +2 -2
  13. package/dist/protocols/rdp/ntlm.js +2 -2
  14. package/dist/protocols/rdp/pdu.js +1 -1
  15. package/dist/protocols/rdp/rail.js +1 -1
  16. package/dist/protocols/rdp/sec.js +3 -3
  17. package/dist/protocols/rdp/session.js +3 -3
  18. package/dist/protocols/rdp/tls.js +3 -3
  19. package/dist/protocols/rdp/vchannel.js +1 -1
  20. package/dist/protocols/rdp/x224.js +1 -1
  21. package/dist/protocols/ssh/kex.js +1 -1
  22. package/dist/protocols/ssh/session.js +4 -3
  23. package/dist/protocols/ssh/session.js.map +2 -2
  24. package/dist/protocols/ssh/transport.js +8 -6
  25. package/dist/protocols/ssh/transport.js.map +2 -2
  26. package/dist/protocols/vnc/session.js +3 -3
  27. package/dist/shared/connection.js +1 -1
  28. package/dist/shared/hosts.js +1 -1
  29. package/package.json +13 -3
  30. package/src/cli/cli.ts +451 -0
  31. package/src/cli/daemon.ts +291 -0
  32. package/src/cli/framebuffer.ts +115 -0
  33. package/src/cli/generated/viewer-bundle.ts +5 -0
  34. package/src/cli/ipc.ts +78 -0
  35. package/src/cli/paths.ts +28 -0
  36. package/src/cli/png.ts +106 -0
  37. package/src/cli/session.ts +263 -0
  38. package/src/cli/shell.ts +171 -0
  39. package/src/cli/transport.ts +86 -0
  40. package/src/cli/viewer-client/assets.d.ts +4 -0
  41. package/src/cli/viewer-client/main.ts +300 -0
  42. package/src/cli/viewer-page.ts +102 -0
  43. package/src/cli/viewer.ts +269 -0
  44. package/src/cli/wsserver.ts +144 -0
  45. package/src/protocols/rdp/client.ts +9 -1
  46. package/src/protocols/ssh/session.ts +1 -0
  47. package/src/protocols/ssh/transport.ts +15 -2
@@ -0,0 +1,269 @@
1
+ /**
2
+ * The window a human watches the session through.
3
+ *
4
+ * The session already exists and already holds the pixels; this is an
5
+ * attachment to it. Viewers may come and go, and there may be none at all --
6
+ * a viewer that could end a session would make supervision a dependency
7
+ * rather than a feature.
8
+ *
9
+ * A viewer that has just attached needs the whole desktop, not a delta, which
10
+ * is exactly what the framebuffer is for: it sends one keyframe on connect and
11
+ * painted rectangles after that.
12
+ */
13
+
14
+ import { createServer } from 'node:http';
15
+ import type net from 'node:net';
16
+ import { randomBytes } from 'node:crypto';
17
+ import { spawn } from 'node:child_process';
18
+ import { deflateSync } from 'node:zlib';
19
+ import type { RdpSession } from './session';
20
+ import type { Shell } from './shell';
21
+ import { accept, type WebSocketPeer } from './wsserver';
22
+ import { PAGE } from './viewer-page';
23
+
24
+ export interface Viewer {
25
+ url: string;
26
+ /** Resolves when a browser has actually attached. */
27
+ opened: Promise<void>;
28
+ /** Pop the window again, for a viewer that was closed by hand. */
29
+ launch(): void;
30
+ close(): void;
31
+ }
32
+
33
+ /**
34
+ * Frame layout, little-endian:
35
+ * u8 kind 1 = keyframe, 2 = rectangle
36
+ * u16 left, u16 top, u16 width, u16 height
37
+ * deflated RGBA
38
+ */
39
+ function encodeRect(kind: number, left: number, top: number, width: number, height: number, rgba: Uint8Array): Buffer {
40
+ const header = Buffer.alloc(9);
41
+ header.writeUInt8(kind, 0);
42
+ header.writeUInt16LE(left, 1);
43
+ header.writeUInt16LE(top, 3);
44
+ header.writeUInt16LE(width, 5);
45
+ header.writeUInt16LE(height, 7);
46
+ return Buffer.concat([header, deflateSync(rgba, { level: 1 })]);
47
+ }
48
+
49
+ export async function startViewer(
50
+ session: RdpSession,
51
+ port: number,
52
+ { launch = true, openShell }: { launch?: boolean; openShell?: () => Promise<Shell> } = {}
53
+ ): Promise<Viewer> {
54
+ const token = randomBytes(16).toString('hex');
55
+ const peers = new Set<WebSocketPeer>();
56
+
57
+ /**
58
+ * Which viewers may send input.
59
+ *
60
+ * The gate lives here rather than in the page, because a gate in the page is
61
+ * not a gate: anything that can open a socket can skip it. A viewer attaches
62
+ * as an observer and has to ask for control, which is also the right default
63
+ * when the session was opened by an agent -- the human watches first.
64
+ */
65
+ const drivers = new WeakSet<WebSocketPeer>();
66
+
67
+ /** Undoes each viewer's subscription to shell output when it goes away. */
68
+ const detachShell = new Map<WebSocketPeer, () => void>();
69
+
70
+ /**
71
+ * Attach a viewer to the terminal, opening it if this is the first ask.
72
+ *
73
+ * The scrollback goes out first, so a viewer that opens the terminal ten
74
+ * minutes into a session sees what has already been run rather than an empty
75
+ * pane that looks broken.
76
+ */
77
+ async function attachShell(peer: WebSocketPeer): Promise<void> {
78
+ if (!openShell) {
79
+ peer.send(JSON.stringify({ type: 'shell-error', message: 'This session was started without a terminal. Restart it with --ssh-user NAME.' }));
80
+ return;
81
+ }
82
+ if (detachShell.has(peer)) return;
83
+
84
+ peer.send(JSON.stringify({ type: 'shell-status', state: 'opening' }));
85
+ let shell: Shell;
86
+ try {
87
+ shell = await openShell();
88
+ } catch (error) {
89
+ peer.send(JSON.stringify({
90
+ type: 'shell-error',
91
+ message: error instanceof Error ? error.message : String(error),
92
+ }));
93
+ return;
94
+ }
95
+
96
+ const history = shell.scrollback;
97
+ if (history.length) {
98
+ peer.send(JSON.stringify({ type: 'shell-data', base64: Buffer.from(history).toString('base64') }));
99
+ }
100
+ peer.send(JSON.stringify({ type: 'shell-status', state: 'open' }));
101
+
102
+ detachShell.set(peer, shell.onData((bytes) => {
103
+ peer.send(JSON.stringify({ type: 'shell-data', base64: Buffer.from(bytes).toString('base64') }));
104
+ }));
105
+
106
+ shellFor.set(peer, shell);
107
+ }
108
+
109
+ const shellFor = new Map<WebSocketPeer, Shell>();
110
+
111
+ let resolveOpened: () => void;
112
+ const opened = new Promise<void>((resolve) => { resolveOpened = resolve; });
113
+
114
+ const server = createServer((request, response) => {
115
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
116
+ if (url.pathname === '/' && url.searchParams.get('t') === token) {
117
+ response.writeHead(200, {
118
+ 'Content-Type': 'text/html; charset=utf-8',
119
+ 'Cache-Control': 'no-store',
120
+ });
121
+ response.end(PAGE);
122
+ return;
123
+ }
124
+ response.writeHead(404, { 'Content-Type': 'text/plain' });
125
+ response.end('Not found');
126
+ });
127
+
128
+ server.on('upgrade', (request, socket) => {
129
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
130
+ if (url.searchParams.get('t') !== token) { socket.destroy(); return; }
131
+
132
+ // Node types an upgrade socket as Duplex; every runtime it has is a
133
+ // net.Socket, which is what lets the peer turn Nagle off.
134
+ const peer = accept(request, socket as net.Socket);
135
+ if (!peer) return;
136
+ peers.add(peer);
137
+ resolveOpened();
138
+
139
+ const { width, height } = session.framebuffer;
140
+ peer.send(JSON.stringify({ type: 'hello', width, height, host: session.options.host }));
141
+ if (width && height) {
142
+ peer.send(encodeRect(1, 0, 0, width, height, session.framebuffer.pixels));
143
+ }
144
+
145
+ peer.addEventListener('message', (event: Event) => {
146
+ const raw = (event as MessageEvent).data as string;
147
+ if (process.env.REMOTECTL_DEBUG) console.log('[viewer] <-', raw);
148
+ try {
149
+ const message = JSON.parse(raw);
150
+
151
+ if (message.type === 'control') {
152
+ if (message.on) drivers.add(peer); else drivers.delete(peer);
153
+ peer.send(JSON.stringify({ type: 'control', on: Boolean(message.on) }));
154
+ return;
155
+ }
156
+
157
+ if (message.type === 'shell-attach') { void attachShell(peer); return; }
158
+
159
+ if (!drivers.has(peer)) return;
160
+
161
+ if (message.type === 'shell-input') {
162
+ shellFor.get(peer)?.write(String(message.text ?? ''));
163
+ return;
164
+ }
165
+ if (message.type === 'shell-resize') {
166
+ shellFor.get(peer)?.resize(Number(message.columns) || 120, Number(message.rows) || 30);
167
+ return;
168
+ }
169
+
170
+ applyInput(session, message);
171
+ } catch (error) {
172
+ // A malformed frame from a viewer must never take the session down.
173
+ console.warn('[viewer] dropped an unreadable message', error);
174
+ }
175
+ });
176
+ peer.addEventListener('close', () => {
177
+ peers.delete(peer);
178
+ detachShell.get(peer)?.();
179
+ detachShell.delete(peer);
180
+ });
181
+ });
182
+
183
+ /**
184
+ * Loopback only. The viewer carries a live desktop and takes input for it.
185
+ *
186
+ * A busy port is not a reason to fail: a second session, or a stale process
187
+ * holding the default, would otherwise take the whole connection down. The
188
+ * chosen port is reported back, so nothing has to guess which one it got.
189
+ */
190
+ const bound = await new Promise<number>((resolve, reject) => {
191
+ const listen = (candidate: number, retry: boolean) => {
192
+ server.once('error', (error: NodeJS.ErrnoException) => {
193
+ if (retry && error.code === 'EADDRINUSE') { listen(0, false); return; }
194
+ reject(error);
195
+ });
196
+ server.listen(candidate, '127.0.0.1', () => {
197
+ const address = server.address();
198
+ resolve(typeof address === 'object' && address ? address.port : candidate);
199
+ });
200
+ };
201
+ listen(port, true);
202
+ });
203
+
204
+ session.onResize((width, height) => {
205
+ for (const peer of peers) {
206
+ peer.send(JSON.stringify({ type: 'resize', width, height }));
207
+ peer.send(encodeRect(1, 0, 0, width, height, session.framebuffer.pixels));
208
+ }
209
+ });
210
+
211
+ session.onPaint((rects) => {
212
+ if (!peers.size) return;
213
+ for (const rect of rects) {
214
+ const payload = encodeRect(2, rect.left, rect.top, rect.width, rect.height, rect.rgba);
215
+ for (const peer of peers) peer.send(payload);
216
+ }
217
+ });
218
+
219
+ const url = `http://127.0.0.1:${bound}/?t=${token}`;
220
+ if (launch) open(url);
221
+
222
+ return {
223
+ url,
224
+ opened,
225
+ launch() { open(url); },
226
+ close() {
227
+ for (const peer of peers) peer.close();
228
+ server.close();
229
+ },
230
+ };
231
+ }
232
+
233
+ function applyInput(session: RdpSession, message: any): void {
234
+ switch (message.type) {
235
+ case 'move':
236
+ session.movePointer(message.x, message.y);
237
+ break;
238
+ case 'button':
239
+ session.pointerButton(message.x, message.y, message.button ?? 0, Boolean(message.pressed));
240
+ break;
241
+ case 'scroll':
242
+ session.scroll(message.x, message.y, message.dx ?? 0, message.dy ?? 0);
243
+ break;
244
+ case 'key':
245
+ session.sendKeyCode(message.code, Boolean(message.pressed));
246
+ break;
247
+ case 'text':
248
+ session.typeText(String(message.text ?? ''));
249
+ break;
250
+ case 'cad':
251
+ session.sendCtrlAltDel();
252
+ break;
253
+ default:
254
+ break;
255
+ }
256
+ }
257
+
258
+ /** Pop the window. A failure here is not a failure of the session. */
259
+ function open(url: string): void {
260
+ const command = process.platform === 'darwin' ? 'open'
261
+ : process.platform === 'win32' ? 'cmd'
262
+ : 'xdg-open';
263
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
264
+ try {
265
+ spawn(command, args, { stdio: 'ignore', detached: true }).unref();
266
+ } catch {
267
+ // Headless, or no browser. The URL was printed; that is enough.
268
+ }
269
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Just enough WebSocket to talk to a browser tab.
3
+ *
4
+ * Node has no WebSocket server, and pulling one in would cost `npx remotectl`
5
+ * its single-download start. What the viewer needs is small: an upgrade
6
+ * handshake, binary frames out, text frames in.
7
+ */
8
+
9
+ import { createHash } from 'node:crypto';
10
+ import type { IncomingMessage } from 'node:http';
11
+ import type net from 'node:net';
12
+
13
+ const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
14
+
15
+ export class WebSocketPeer extends EventTarget {
16
+ #socket: net.Socket;
17
+ #buffer = Buffer.alloc(0);
18
+ /** Stop writing. Set as soon as closing begins, from either end. */
19
+ #closed = false;
20
+ /**
21
+ * Whether the close event has gone out.
22
+ *
23
+ * Kept apart from #closed on purpose: one flag doing both jobs meant a peer
24
+ * that closed itself -- which is what a browser tab does when it goes away --
25
+ * swallowed its own close event, and the session went on sending frames to a
26
+ * viewer that had left.
27
+ */
28
+ #reported = false;
29
+
30
+ constructor(socket: net.Socket) {
31
+ super();
32
+ this.#socket = socket;
33
+ socket.on('data', (chunk: Buffer) => this.#onData(chunk));
34
+ socket.on('close', () => this.#onClose());
35
+ socket.on('error', () => this.#onClose());
36
+ }
37
+
38
+ get closed(): boolean { return this.#closed; }
39
+
40
+ send(data: string | Uint8Array): void {
41
+ if (this.#closed) return;
42
+ const isText = typeof data === 'string';
43
+ const payload = isText ? Buffer.from(data, 'utf8') : Buffer.from(data);
44
+ this.#socket.write(frame(isText ? 0x1 : 0x2, payload));
45
+ }
46
+
47
+ close(): void {
48
+ if (!this.#closed) {
49
+ this.#closed = true;
50
+ try { this.#socket.end(frame(0x8, Buffer.alloc(0))); } catch { /* already gone */ }
51
+ }
52
+ this.#onClose();
53
+ }
54
+
55
+ #onClose(): void {
56
+ this.#closed = true;
57
+ if (this.#reported) return;
58
+ this.#reported = true;
59
+ this.dispatchEvent(new Event('close'));
60
+ }
61
+
62
+ #onData(chunk: Buffer): void {
63
+ if (process.env.REMOTECTL_DEBUG) console.log('[ws] data', chunk.length, 'bytes');
64
+ this.#buffer = Buffer.concat([this.#buffer, chunk]);
65
+
66
+ for (;;) {
67
+ const parsed = readFrame(this.#buffer);
68
+ if (!parsed) return;
69
+ this.#buffer = parsed.rest;
70
+
71
+ if (parsed.opcode === 0x8) { this.close(); return; }
72
+ if (parsed.opcode === 0x9) { this.#socket.write(frame(0xa, parsed.payload)); continue; }
73
+ if (parsed.opcode === 0x1) {
74
+ this.dispatchEvent(new MessageEvent('message', { data: parsed.payload.toString('utf8') }));
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ /** Server frames are never masked, and the viewer sends nothing large. */
81
+ function frame(opcode: number, payload: Buffer): Buffer {
82
+ const length = payload.length;
83
+ let header: Buffer;
84
+
85
+ if (length < 126) {
86
+ header = Buffer.from([0x80 | opcode, length]);
87
+ } else if (length < 65536) {
88
+ header = Buffer.alloc(4);
89
+ header[0] = 0x80 | opcode;
90
+ header[1] = 126;
91
+ header.writeUInt16BE(length, 2);
92
+ } else {
93
+ header = Buffer.alloc(10);
94
+ header[0] = 0x80 | opcode;
95
+ header[1] = 127;
96
+ header.writeBigUInt64BE(BigInt(length), 2);
97
+ }
98
+
99
+ return Buffer.concat([header, payload]);
100
+ }
101
+
102
+ function readFrame(buffer: Buffer) {
103
+ if (buffer.length < 2) return null;
104
+
105
+ const opcode = buffer[0] & 0x0f;
106
+ const masked = (buffer[1] & 0x80) !== 0;
107
+ let length = buffer[1] & 0x7f;
108
+ let offset = 2;
109
+
110
+ if (length === 126) {
111
+ if (buffer.length < 4) return null;
112
+ length = buffer.readUInt16BE(2);
113
+ offset = 4;
114
+ } else if (length === 127) {
115
+ if (buffer.length < 10) return null;
116
+ length = Number(buffer.readBigUInt64BE(2));
117
+ offset = 10;
118
+ }
119
+
120
+ const maskKey = masked ? buffer.subarray(offset, offset + 4) : null;
121
+ if (masked) offset += 4;
122
+ if (buffer.length < offset + length) return null;
123
+
124
+ const payload = Buffer.from(buffer.subarray(offset, offset + length));
125
+ if (maskKey) for (let i = 0; i < payload.length; i++) payload[i] ^= maskKey[i % 4];
126
+
127
+ return { opcode, payload, rest: buffer.subarray(offset + length) };
128
+ }
129
+
130
+ /** Complete the HTTP upgrade a browser sends, and hand back the peer. */
131
+ export function accept(request: IncomingMessage, socket: net.Socket): WebSocketPeer | null {
132
+ const key = request.headers['sec-websocket-key'];
133
+ if (typeof key !== 'string') { socket.destroy(); return null; }
134
+
135
+ const acceptKey = createHash('sha1').update(key + GUID).digest('base64');
136
+ socket.write(
137
+ 'HTTP/1.1 101 Switching Protocols\r\n'
138
+ + 'Upgrade: websocket\r\n'
139
+ + 'Connection: Upgrade\r\n'
140
+ + `Sec-WebSocket-Accept: ${acceptKey}\r\n\r\n`
141
+ );
142
+ socket.setNoDelay(true);
143
+ return new WebSocketPeer(socket);
144
+ }
@@ -150,6 +150,8 @@ export class RdpClient extends EventTarget {
150
150
  keyboardLayout: 0x409,
151
151
  clientName: 'cf-gateway',
152
152
  security: 'auto',
153
+ /** Returns a WebSocket-shaped transport for `url`; default is a real one. */
154
+ openTransport: null,
153
155
  clipboard: true,
154
156
  remoteApp: null,
155
157
  performance: PERF_DISABLE_WALLPAPER | PERF_DISABLE_FULLWINDOWDRAG | PERF_DISABLE_MENUANIMATIONS,
@@ -231,7 +233,13 @@ export class RdpClient extends EventTarget {
231
233
  this.remoteAppFitTimer = null;
232
234
  this.remoteAppCloseTimer = null;
233
235
 
234
- this.socket = new WebSocket(this.url);
236
+ // The transport is injected so this client can run anywhere. In a browser
237
+ // it is a WebSocket to the gateway's relay; in the CLI it is a TCP socket
238
+ // straight at the host. Everything above it -- X.224, TLS, CredSSP, MCS --
239
+ // is the same either way, which is the whole reason it is a seam.
240
+ this.socket = this.options.openTransport
241
+ ? this.options.openTransport(this.url)
242
+ : new WebSocket(this.url);
235
243
  this.socket.binaryType = 'arraybuffer';
236
244
  this.socket.addEventListener('open', () => this.#onOpen());
237
245
  this.socket.addEventListener('message', (event) => this.#onMessage(event));
@@ -255,6 +255,7 @@ export class SshSession extends EventTarget {
255
255
 
256
256
  this.transport = new SshTransport(url, {
257
257
  verifyHost: options.verifyHost || (async () => true),
258
+ openTransport: options.openTransport || null,
258
259
  log: (step: number, detail) => console.info(`[SSH] ${step}`, detail),
259
260
  });
260
261
 
@@ -87,6 +87,7 @@ export class SshTransport extends EventTarget {
87
87
  verifyHost: (info: any) => Promise<boolean> | boolean;
88
88
  log: (...args: any[]) => void;
89
89
  socket: WebSocket | null;
90
+ openTransport: ((url: string) => any) | null;
90
91
  closed: boolean;
91
92
  buffer: Uint8Array;
92
93
  serverVersion: string;
@@ -113,12 +114,21 @@ export class SshTransport extends EventTarget {
113
114
  * @param {(info: object) => Promise<boolean>} options.verifyHost decides
114
115
  * whether an unknown or changed host key may be used.
115
116
  */
116
- constructor(url: string, { verifyHost, log }: { verifyHost?: (info: any) => Promise<boolean> | boolean; log?: (...args: any[]) => void } = {}) {
117
+ constructor(
118
+ url: string,
119
+ { verifyHost, log, openTransport }: {
120
+ verifyHost?: (info: any) => Promise<boolean> | boolean;
121
+ log?: (...args: any[]) => void;
122
+ /** Returns a WebSocket-shaped transport for `url`; default is a real one. */
123
+ openTransport?: ((url: string) => any) | null;
124
+ } = {}
125
+ ) {
117
126
  super();
118
127
 
119
128
  this.url = url;
120
129
  this.verifyHost = verifyHost || (() => true);
121
130
  this.log = log || (() => {});
131
+ this.openTransport = openTransport || null;
122
132
 
123
133
  this.socket = null;
124
134
  this.closed = false;
@@ -142,7 +152,10 @@ export class SshTransport extends EventTarget {
142
152
  // --- connection ----------------------------------------------------------
143
153
 
144
154
  connect() {
145
- this.socket = new WebSocket(this.url);
155
+ // Injected so this client can run anywhere: a WebSocket to the gateway in
156
+ // a browser, a TCP socket straight at the host in the CLI. The key
157
+ // exchange and everything above it is identical either way.
158
+ this.socket = this.openTransport ? this.openTransport(this.url) : new WebSocket(this.url);
146
159
  this.socket.binaryType = 'arraybuffer';
147
160
 
148
161
  this.socket.addEventListener('open', () => {