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,291 @@
1
+ /**
2
+ * A session that outlives the command that started it.
3
+ *
4
+ * An RDP handshake costs a second or two and everything after it is stateful:
5
+ * a click, then a screenshot of what the click did, are two halves of one
6
+ * observation. A tool that reconnects between them can never see the result of
7
+ * its own action, so the session lives here and commands attach to it.
8
+ *
9
+ * The viewer attaches the same way, and so does the terminal. None of them own
10
+ * the session; it outlives all of them and ends when it is told to.
11
+ */
12
+
13
+ import net from 'node:net';
14
+ import { unlinkSync, writeFileSync, existsSync } from 'node:fs';
15
+ import { RdpSession, type SessionOptions } from './session';
16
+ import { Shell } from './shell';
17
+ import { downscale, encodePng } from './png';
18
+ import { startViewer, type Viewer } from './viewer';
19
+ import { readMessages, writeMessage, type Request } from './ipc';
20
+ import { ensureRunDir, metaPath, socketPath } from './paths';
21
+
22
+ export interface DaemonOptions extends SessionOptions {
23
+ name: string;
24
+ view: boolean;
25
+ launchBrowser: boolean;
26
+ viewPort: number;
27
+ /** Account for the terminal, when one is wanted. Blank leaves SSH unopened. */
28
+ sshUsername: string;
29
+ sshPort: number;
30
+ idleMs: number;
31
+ }
32
+
33
+ export async function runDaemon(options: DaemonOptions): Promise<void> {
34
+ ensureRunDir();
35
+
36
+ const session = new RdpSession(options);
37
+ const size = await session.connect();
38
+
39
+ let shell: Shell | null = null;
40
+ let shellStarting: Promise<Shell> | null = null;
41
+
42
+ /**
43
+ * The terminal is opened on first use rather than at connect time. Most
44
+ * sessions never ask for one, and a second sign-in that nobody wanted is a
45
+ * second chance to lock an account out.
46
+ */
47
+ function openShell(): Promise<Shell> {
48
+ if (shell) return Promise.resolve(shell);
49
+ if (shellStarting) return shellStarting;
50
+
51
+ shellStarting = (async () => {
52
+ const started = new Shell({
53
+ host: options.host,
54
+ port: options.sshPort,
55
+ username: options.sshUsername || options.username,
56
+ password: options.password,
57
+ });
58
+ await started.connect();
59
+ shell = started;
60
+ return started;
61
+ })();
62
+
63
+ shellStarting.catch(() => { shellStarting = null; });
64
+ return shellStarting;
65
+ }
66
+
67
+ const startedAt = new Date().toISOString();
68
+
69
+ /**
70
+ * What `ai-remote list` reads. Rewritten whenever the viewer comes or goes,
71
+ * so the listing never advertises a window that is not there.
72
+ */
73
+ function writeMeta(): void {
74
+ writeFileSync(metaPath(options.name), JSON.stringify({
75
+ name: options.name,
76
+ host: options.host,
77
+ port: options.port,
78
+ pid: process.pid,
79
+ viewer: viewer?.url ?? null,
80
+ width: session.framebuffer.width || size.width,
81
+ height: session.framebuffer.height || size.height,
82
+ startedAt,
83
+ }, null, 2));
84
+ }
85
+
86
+ /**
87
+ * The viewer is not part of the session; it attaches to one.
88
+ *
89
+ * So it can be opened and closed while the session keeps running, and a
90
+ * session with no viewer is not a lesser session -- the framebuffer is the
91
+ * authority either way.
92
+ */
93
+ // Annotated rather than inferred: every assignment happens inside a closure,
94
+ // which control-flow analysis cannot follow, so it would otherwise narrow to
95
+ // `null` and then to `never` at the first use.
96
+ let viewer: Viewer | null = null as Viewer | null;
97
+
98
+ async function openViewer(launch: boolean): Promise<Viewer> {
99
+ if (viewer) {
100
+ if (launch) viewer.launch();
101
+ return viewer;
102
+ }
103
+ viewer = await startViewer(session, options.viewPort, { launch, openShell });
104
+ writeMeta();
105
+ return viewer;
106
+ }
107
+
108
+ function closeViewer(): boolean {
109
+ if (!viewer) return false;
110
+ viewer.close();
111
+ viewer = null;
112
+ writeMeta();
113
+ return true;
114
+ }
115
+
116
+ if (options.view) await openViewer(options.launchBrowser);
117
+
118
+ const path = socketPath(options.name);
119
+ if (existsSync(path)) unlinkSync(path);
120
+
121
+ let lastUsed = Date.now();
122
+ const touch = () => { lastUsed = Date.now(); };
123
+
124
+ const server = net.createServer((socket) => {
125
+ readMessages(socket, async (message: Request) => {
126
+ touch();
127
+ try {
128
+ const result = await handle(message);
129
+ writeMessage(socket, { id: message.id, ok: true, result });
130
+ } catch (error) {
131
+ writeMessage(socket, {
132
+ id: message.id,
133
+ ok: false,
134
+ error: error instanceof Error ? error.message : String(error),
135
+ });
136
+ }
137
+ });
138
+ });
139
+
140
+ async function handle(message: Request): Promise<unknown> {
141
+ const args = (message.args ?? {}) as Record<string, any>;
142
+
143
+ switch (message.op) {
144
+ case 'view-open': {
145
+ const opened = await openViewer(args.launch !== false);
146
+ return { viewer: opened.url };
147
+ }
148
+
149
+ case 'view-close':
150
+ return { closed: closeViewer() };
151
+
152
+ case 'status':
153
+ return {
154
+ host: options.host,
155
+ port: options.port,
156
+ name: options.name,
157
+ width: session.framebuffer.width,
158
+ height: session.framebuffer.height,
159
+ connected: session.connected,
160
+ viewer: viewer?.url ?? null,
161
+ shell: shell ? 'open' : shellStarting ? 'opening' : 'closed',
162
+ pid: process.pid,
163
+ };
164
+
165
+ case 'shot': {
166
+ await session.settle({ quietMs: 250, timeoutMs: 3000 });
167
+ const shot = session.framebuffer.snapshot();
168
+ const scaled = args.maxEdge ? downscale(shot.rgba, shot.width, shot.height, args.maxEdge) : shot;
169
+ const png = encodePng(scaled.rgba, scaled.width, scaled.height);
170
+ if (args.file) {
171
+ writeFileSync(args.file, png);
172
+ return { file: args.file, width: scaled.width, height: scaled.height };
173
+ }
174
+ return { base64: Buffer.from(png).toString('base64'), width: scaled.width, height: scaled.height };
175
+ }
176
+
177
+ case 'script':
178
+ return { steps: await runScript(session, String(args.script ?? '')) };
179
+
180
+ case 'exec': {
181
+ const live = await openShell();
182
+ const result = await live.runCommand(String(args.command ?? ''));
183
+ return result;
184
+ }
185
+
186
+ case 'shell-open':
187
+ await openShell();
188
+ return { shell: 'open' };
189
+
190
+ case 'refresh':
191
+ session.requestFullRepaint();
192
+ return {};
193
+
194
+ case 'close':
195
+ setTimeout(() => shutdown('asked to close'), 50);
196
+ return { closing: true };
197
+
198
+ default:
199
+ throw new Error(`Unknown operation "${message.op}".`);
200
+ }
201
+ }
202
+
203
+ await new Promise<void>((resolve) => server.listen(path, resolve));
204
+
205
+ writeMeta();
206
+
207
+ // A session whose host went away is not worth keeping a socket open for.
208
+ session.client.addEventListener('close', () => shutdown('the host closed the connection'));
209
+
210
+ const idleTimer = options.idleMs > 0
211
+ ? setInterval(() => {
212
+ if (Date.now() - lastUsed > options.idleMs) shutdown('idle');
213
+ }, 30_000)
214
+ : null;
215
+
216
+ let shuttingDown = false;
217
+ function shutdown(why: string): void {
218
+ if (shuttingDown) return;
219
+ shuttingDown = true;
220
+ console.log(`[session] closing: ${why}`);
221
+
222
+ if (idleTimer) clearInterval(idleTimer);
223
+ viewer?.close();
224
+ shell?.disconnect();
225
+ session.disconnect();
226
+ server.close();
227
+ for (const file of [path, metaPath(options.name)]) {
228
+ try { unlinkSync(file); } catch { /* already gone */ }
229
+ }
230
+ setTimeout(() => process.exit(0), 100);
231
+ }
232
+
233
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
234
+ process.on(signal, () => shutdown(signal));
235
+ }
236
+
237
+ console.log(`[session] ${options.name} ready at ${size.width}x${size.height}`);
238
+ if (viewer) console.log(`[session] viewer ${viewer.url}`);
239
+ }
240
+
241
+ /**
242
+ * Run several steps against one live session.
243
+ *
244
+ * Shared by the `do` command and the daemon, because they mean the same thing:
245
+ * a sequence that keeps its own state between steps.
246
+ */
247
+ export async function runScript(session: RdpSession, script: string): Promise<string[]> {
248
+ const done: string[] = [];
249
+
250
+ for (const raw of script.split(';')) {
251
+ const step = raw.trim();
252
+ if (!step) continue;
253
+
254
+ const [verb, ...rest] = step.split(/\s+/);
255
+ const argument = rest.join(' ');
256
+ const numbers = () => argument.split(',').map(Number);
257
+
258
+ switch (verb) {
259
+ case 'move': { const [x, y] = numbers(); session.movePointer(x, y); break; }
260
+ case 'click': {
261
+ const [x, y] = numbers();
262
+ const named = argument.split(',')[2]?.trim();
263
+ session.click(x, y, { right: 2, middle: 1 }[named ?? ''] ?? 0);
264
+ break;
265
+ }
266
+ case 'dblclick': { const [x, y] = numbers(); session.click(x, y, 0); session.click(x, y, 0); break; }
267
+ case 'scroll': { const [x, y, dx, dy] = numbers(); session.scroll(x, y, dx || 0, dy || 0); break; }
268
+ case 'type': session.typeText(argument); break;
269
+ case 'key': {
270
+ const codes = argument.split('+').map((code) => code.trim()).filter(Boolean);
271
+ for (const code of codes) session.sendKeyCode(code, true);
272
+ for (const code of [...codes].reverse()) session.sendKeyCode(code, false);
273
+ break;
274
+ }
275
+ case 'cad': session.sendCtrlAltDel(); break;
276
+ case 'wait': await new Promise((resolve) => setTimeout(resolve, Number(argument) || 500)); break;
277
+ case 'shot': {
278
+ await session.settle({ quietMs: 350, timeoutMs: 4000 });
279
+ const shot = session.framebuffer.snapshot();
280
+ writeFileSync(argument || 'screen.png', encodePng(shot.rgba, shot.width, shot.height));
281
+ break;
282
+ }
283
+ default:
284
+ throw new Error(`Unknown step "${verb}" in the script.`);
285
+ }
286
+
287
+ done.push(step);
288
+ }
289
+
290
+ return done;
291
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The desktop as pixels, held in memory.
3
+ *
4
+ * In a browser the canvas is both the surface the protocol paints into and the
5
+ * thing a person looks at. Headless, those come apart: this holds the pixels so
6
+ * a screenshot can be taken at any moment, and the viewer -- when there is one
7
+ * -- is a separate attachment fed the same rectangles.
8
+ *
9
+ * Which is what makes the viewer optional. The framebuffer is the authority,
10
+ * so a session with nobody watching is not a degraded session.
11
+ */
12
+
13
+ import { decodeBitmapRect } from '../protocols/rdp/rle';
14
+
15
+ export interface PaintedRect {
16
+ left: number;
17
+ top: number;
18
+ width: number;
19
+ height: number;
20
+ rgba: Uint8Array;
21
+ }
22
+
23
+ /** Palettes only exist for 8bpp updates; anything else ignores this. */
24
+ const NO_PALETTE = new Uint32Array(256);
25
+
26
+ export class Framebuffer {
27
+ width = 0;
28
+ height = 0;
29
+ /** RGBA, row-major, `width * height * 4` bytes. */
30
+ pixels = new Uint8Array(0);
31
+ /**
32
+ * Bumped on anything that changes what a viewer should be showing, including
33
+ * a resize.
34
+ */
35
+ generation = 0;
36
+ /**
37
+ * Rectangles painted. Kept apart from `generation` because "the desktop has
38
+ * been sized" and "the desktop has sent pixels" are different questions, and
39
+ * a screenshot taken on the strength of the first one is a black image.
40
+ */
41
+ paints = 0;
42
+
43
+ palette: Uint32Array | null = null;
44
+
45
+ resize(width: number, height: number): void {
46
+ if (width === this.width && height === this.height) return;
47
+ this.width = width;
48
+ this.height = height;
49
+ this.pixels = new Uint8Array(width * height * 4);
50
+ // Opaque black, so an unpainted desktop reads as a screen rather than as
51
+ // transparency that a PNG viewer will render as a checkerboard.
52
+ for (let i = 3; i < this.pixels.length; i += 4) this.pixels[i] = 255;
53
+ this.generation++;
54
+ }
55
+
56
+ /**
57
+ * Decode and blit one update. Returns what was painted, so a viewer can be
58
+ * sent the same rectangles without decoding them a second time.
59
+ */
60
+ draw(rects: readonly any[], palette: Uint32Array | null): PaintedRect[] {
61
+ if (palette) this.palette = palette;
62
+ const painted: PaintedRect[] = [];
63
+
64
+ for (const rect of rects) {
65
+ if (!rect.width || !rect.height) continue;
66
+ let rgba: Uint8Array;
67
+ try {
68
+ // decodeBitmapRect hands back a Uint8ClampedArray, which is the right
69
+ // thing for a canvas and the wrong thing for everything else here; the
70
+ // view costs nothing and shares the same bytes.
71
+ const decoded = decodeBitmapRect(rect, this.palette ?? NO_PALETTE);
72
+ rgba = new Uint8Array(decoded.buffer, decoded.byteOffset, decoded.byteLength);
73
+ } catch (error) {
74
+ // One malformed rectangle must not end a session; the next full update
75
+ // repaints the area anyway. Same rule the canvas renderer follows.
76
+ console.warn('[rdp] dropped an undecodable rectangle', {
77
+ geometry: `${rect.width}x${rect.height}+${rect.left}+${rect.top}`,
78
+ error: error instanceof Error ? error.message : String(error),
79
+ });
80
+ continue;
81
+ }
82
+
83
+ this.blit(rect.left, rect.top, rect.width, rect.height, rgba);
84
+ painted.push({ left: rect.left, top: rect.top, width: rect.width, height: rect.height, rgba });
85
+ }
86
+
87
+ if (painted.length) {
88
+ this.generation++;
89
+ this.paints += painted.length;
90
+ }
91
+ return painted;
92
+ }
93
+
94
+ /** Copy one RGBA rectangle in, clipped to the framebuffer. */
95
+ blit(left: number, top: number, width: number, height: number, rgba: Uint8Array): void {
96
+ for (let row = 0; row < height; row++) {
97
+ const y = top + row;
98
+ if (y < 0 || y >= this.height) continue;
99
+
100
+ const from = row * width * 4;
101
+ const columns = Math.min(width, this.width - left);
102
+ if (columns <= 0) continue;
103
+
104
+ this.pixels.set(
105
+ rgba.subarray(from, from + columns * 4),
106
+ (y * this.width + left) * 4
107
+ );
108
+ }
109
+ }
110
+
111
+ /** A copy, so a caller holding a screenshot is not looking at live memory. */
112
+ snapshot(): { rgba: Uint8Array; width: number; height: number } {
113
+ return { rgba: this.pixels.slice(), width: this.width, height: this.height };
114
+ }
115
+ }