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
package/src/cli/ipc.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The conversation between a command and the session it attaches to.
3
+ *
4
+ * Newline-delimited JSON over a unix socket. Nothing here crosses a network,
5
+ * so the format is chosen to be obvious in a log rather than compact.
6
+ */
7
+
8
+ import net from 'node:net';
9
+ import { StringDecoder } from 'node:string_decoder';
10
+
11
+ export interface Request {
12
+ id: number;
13
+ op: string;
14
+ args?: Record<string, unknown>;
15
+ }
16
+
17
+ export interface Response {
18
+ id: number;
19
+ ok: boolean;
20
+ result?: unknown;
21
+ error?: string;
22
+ /** The exit code this failure should produce, when it has a natural one. */
23
+ code?: number;
24
+ }
25
+
26
+ /**
27
+ * Read newline-delimited JSON off a socket, one object at a time.
28
+ *
29
+ * The decoder is stateful rather than a `toString()` per chunk, because a read
30
+ * boundary can fall inside a multi-byte character: `type HOST "café"` split at
31
+ * the wrong byte used to arrive as a replacement character. It holds the
32
+ * incomplete tail until the rest of it turns up.
33
+ */
34
+ export function readMessages(socket: net.Socket, onMessage: (value: any) => void): void {
35
+ const decoder = new StringDecoder('utf8');
36
+ let buffer = '';
37
+ socket.on('data', (chunk) => {
38
+ buffer += decoder.write(chunk as Buffer);
39
+ for (;;) {
40
+ const newline = buffer.indexOf('\n');
41
+ if (newline === -1) return;
42
+ const line = buffer.slice(0, newline);
43
+ buffer = buffer.slice(newline + 1);
44
+ if (!line.trim()) continue;
45
+ try { onMessage(JSON.parse(line)); } catch { /* a truncated write; drop it */ }
46
+ }
47
+ });
48
+ }
49
+
50
+ export const writeMessage = (socket: net.Socket, value: unknown): void => {
51
+ socket.write(`${JSON.stringify(value)}\n`);
52
+ };
53
+
54
+ /** Send one request to a running session and wait for its answer. */
55
+ export function request(path: string, op: string, args: Record<string, unknown> = {}, timeoutMs = 120_000): Promise<Response> {
56
+ return new Promise((resolve, reject) => {
57
+ const socket = net.connect(path);
58
+ let settled = false;
59
+
60
+ const finish = (error: Error | null, response?: Response) => {
61
+ if (settled) return;
62
+ settled = true;
63
+ clearTimeout(timer);
64
+ socket.destroy();
65
+ if (error) reject(error); else resolve(response!);
66
+ };
67
+
68
+ const timer = setTimeout(() => finish(new Error(`The session did not answer "${op}" in time.`)), timeoutMs);
69
+
70
+ socket.on('connect', () => writeMessage(socket, { id: 1, op, args }));
71
+ socket.on('error', (error: NodeJS.ErrnoException) => {
72
+ // ENOENT and ECONNREFUSED both mean the same thing to a caller: the
73
+ // session named by that socket is not running.
74
+ finish(Object.assign(error, { notRunning: error.code === 'ENOENT' || error.code === 'ECONNREFUSED' }));
75
+ });
76
+ readMessages(socket, (message) => finish(null, message as Response));
77
+ });
78
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Where a running session leaves its door open.
3
+ *
4
+ * One directory per user, mode 0700, because the socket in it accepts input
5
+ * for a live desktop: anything that can reach it can type on the machine.
6
+ */
7
+
8
+ import { mkdirSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ export const ROOT = join(homedir(), '.ai-remote');
13
+ export const RUN_DIR = join(ROOT, 'run');
14
+
15
+ export function ensureRunDir(): string {
16
+ mkdirSync(RUN_DIR, { recursive: true, mode: 0o700 });
17
+ return RUN_DIR;
18
+ }
19
+
20
+ /** A stable name for a target, so a second command finds the first one's session. */
21
+ export function sessionName(host: string, port: number, explicit?: string): string {
22
+ if (explicit) return explicit.replace(/[^\w.-]/g, '_');
23
+ return `${host}_${port}`.replace(/[^\w.-]/g, '_');
24
+ }
25
+
26
+ export const socketPath = (name: string) => join(RUN_DIR, `${name}.sock`);
27
+ export const metaPath = (name: string) => join(RUN_DIR, `${name}.json`);
28
+ export const logPath = (name: string) => join(RUN_DIR, `${name}.log`);
package/src/cli/png.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * A PNG writer, so a screenshot needs no dependency.
3
+ *
4
+ * The browser encodes JPEG through the canvas it already has. Node has no
5
+ * image encoder at all, but it does have DEFLATE -- which is the only
6
+ * compression PNG defines -- so the rest is three chunks and two checksums.
7
+ */
8
+
9
+ import { deflateSync } from 'node:zlib';
10
+
11
+ const CRC_TABLE = (() => {
12
+ const table = new Uint32Array(256);
13
+ for (let n = 0; n < 256; n++) {
14
+ let c = n;
15
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
16
+ table[n] = c >>> 0;
17
+ }
18
+ return table;
19
+ })();
20
+
21
+ function crc32(bytes: Uint8Array): number {
22
+ let c = 0xffffffff;
23
+ for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
24
+ return (c ^ 0xffffffff) >>> 0;
25
+ }
26
+
27
+ function chunk(type: string, body: Uint8Array): Uint8Array {
28
+ const out = new Uint8Array(12 + body.length);
29
+ const view = new DataView(out.buffer);
30
+ view.setUint32(0, body.length);
31
+ for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
32
+ out.set(body, 8);
33
+ view.setUint32(8 + body.length, crc32(out.subarray(4, 8 + body.length)));
34
+ return out;
35
+ }
36
+
37
+ /** RGBA in, PNG out. Rows are filtered `None`, which DEFLATE handles well enough. */
38
+ export function encodePng(rgba: Uint8Array, width: number, height: number): Uint8Array {
39
+ const stride = width * 4;
40
+ const raw = new Uint8Array((stride + 1) * height);
41
+ for (let y = 0; y < height; y++) {
42
+ raw[y * (stride + 1)] = 0;
43
+ raw.set(rgba.subarray(y * stride, y * stride + stride), y * (stride + 1) + 1);
44
+ }
45
+
46
+ const ihdr = new Uint8Array(13);
47
+ const view = new DataView(ihdr.buffer);
48
+ view.setUint32(0, width);
49
+ view.setUint32(4, height);
50
+ ihdr[8] = 8; // bit depth
51
+ ihdr[9] = 6; // colour type: truecolour with alpha
52
+ ihdr[10] = 0; // compression: deflate
53
+ ihdr[11] = 0; // filter: adaptive
54
+ ihdr[12] = 0; // interlace: none
55
+
56
+ const parts = [
57
+ new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
58
+ chunk('IHDR', ihdr),
59
+ chunk('IDAT', new Uint8Array(deflateSync(raw, { level: 6 }))),
60
+ chunk('IEND', new Uint8Array(0)),
61
+ ];
62
+
63
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
64
+ const png = new Uint8Array(total);
65
+ let offset = 0;
66
+ for (const part of parts) {
67
+ png.set(part, offset);
68
+ offset += part.length;
69
+ }
70
+ return png;
71
+ }
72
+
73
+ /**
74
+ * Nearest-neighbour downscale to fit `maxEdge`.
75
+ *
76
+ * A box filter would look better, but this frame exists to be read -- by a
77
+ * model or by a person checking what happened -- and nearest-neighbour keeps
78
+ * single-pixel text strokes from being averaged into grey.
79
+ */
80
+ export function downscale(
81
+ rgba: Uint8Array,
82
+ width: number,
83
+ height: number,
84
+ maxEdge: number
85
+ ): { rgba: Uint8Array; width: number; height: number } {
86
+ const scale = Math.min(1, maxEdge / Math.max(width, height));
87
+ if (scale >= 1) return { rgba, width, height };
88
+
89
+ const outWidth = Math.max(1, Math.round(width * scale));
90
+ const outHeight = Math.max(1, Math.round(height * scale));
91
+ const out = new Uint8Array(outWidth * outHeight * 4);
92
+
93
+ for (let y = 0; y < outHeight; y++) {
94
+ const sourceY = Math.min(height - 1, Math.floor(y / scale));
95
+ for (let x = 0; x < outWidth; x++) {
96
+ const sourceX = Math.min(width - 1, Math.floor(x / scale));
97
+ const from = (sourceY * width + sourceX) * 4;
98
+ const to = (y * outWidth + x) * 4;
99
+ out[to] = rgba[from];
100
+ out[to + 1] = rgba[from + 1];
101
+ out[to + 2] = rgba[from + 2];
102
+ out[to + 3] = rgba[from + 3];
103
+ }
104
+ }
105
+ return { rgba: out, width: outWidth, height: outHeight };
106
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * One RDP session, headless.
3
+ *
4
+ * The same client the browser runs, given a TCP transport and a framebuffer
5
+ * instead of a WebSocket and a canvas. Nothing above the transport knows the
6
+ * difference, which is the point of the seam.
7
+ */
8
+
9
+ import { RdpClient } from '../protocols/rdp/client';
10
+ import { Framebuffer, type PaintedRect } from './framebuffer';
11
+ import { TcpTransport } from './transport';
12
+
13
+ export interface SessionOptions {
14
+ host: string;
15
+ port: number;
16
+ username: string;
17
+ password: string;
18
+ domain: string;
19
+ width: number;
20
+ height: number;
21
+ security: 'auto' | 'nla' | 'tls' | 'rdp';
22
+ /** Shown in this session's own log lines and sent to the host. */
23
+ clientName: string;
24
+ /**
25
+ * Where the bytes go. Defaults to a TCP socket at `host:port`.
26
+ *
27
+ * Injectable for the same reason the client's is: a test can drive a whole
28
+ * session against a mock host with no network, and a future gateway mode can
29
+ * point the same session at a relay instead.
30
+ */
31
+ openTransport?: () => any;
32
+ }
33
+
34
+ export interface PointerState {
35
+ x: number;
36
+ y: number;
37
+ }
38
+
39
+ type Listener = (rects: PaintedRect[], framebuffer: Framebuffer) => void;
40
+
41
+ export class RdpSession {
42
+ readonly framebuffer = new Framebuffer();
43
+ readonly options: SessionOptions;
44
+
45
+ client: any;
46
+ connected = false;
47
+ lastError = '';
48
+
49
+ #painters = new Set<Listener>();
50
+ #resizers = new Set<(width: number, height: number) => void>();
51
+ #pointer: PointerState = { x: 0, y: 0 };
52
+
53
+ constructor(options: SessionOptions) {
54
+ this.options = options;
55
+
56
+ this.client = new RdpClient(`tcp://${options.host}:${options.port}`, {
57
+ width: options.width,
58
+ height: options.height,
59
+ username: options.username,
60
+ password: options.password,
61
+ domain: options.domain,
62
+ security: options.security,
63
+ clientName: options.clientName,
64
+ clipboard: true,
65
+ openTransport: options.openTransport ?? (() => new TcpTransport(options.host, options.port)),
66
+ });
67
+
68
+ this.client.addEventListener('ready', (event: any) => {
69
+ const { width, height } = event.detail;
70
+ this.framebuffer.resize(width, height);
71
+ this.connected = true;
72
+ for (const notify of this.#resizers) notify(width, height);
73
+ });
74
+
75
+ this.client.addEventListener('resize', (event: any) => {
76
+ const { width, height } = event.detail;
77
+ this.framebuffer.resize(width, height);
78
+ for (const notify of this.#resizers) notify(width, height);
79
+ });
80
+
81
+ this.client.addEventListener('bitmap', (event: any) => {
82
+ const painted = this.framebuffer.draw(event.detail.rects, event.detail.palette ?? null);
83
+ if (painted.length) for (const notify of this.#painters) notify(painted, this.framebuffer);
84
+ });
85
+
86
+ this.client.addEventListener('palette', (event: any) => {
87
+ this.framebuffer.palette = event.detail;
88
+ });
89
+
90
+ this.client.addEventListener('error', (event: any) => {
91
+ this.lastError = event.detail?.message || '';
92
+ });
93
+
94
+ this.client.addEventListener('close', () => {
95
+ this.connected = false;
96
+ });
97
+ }
98
+
99
+ onPaint(listener: Listener): () => void {
100
+ this.#painters.add(listener);
101
+ return () => this.#painters.delete(listener);
102
+ }
103
+
104
+ onResize(listener: (width: number, height: number) => void): () => void {
105
+ this.#resizers.add(listener);
106
+ return () => this.#resizers.delete(listener);
107
+ }
108
+
109
+ /** Resolves when the desktop is up, or rejects with what the host said. */
110
+ connect(timeoutMs = 30_000): Promise<{ width: number; height: number }> {
111
+ return new Promise((resolve, reject) => {
112
+ let settled = false;
113
+ const finish = (error: Error | null, size?: { width: number; height: number }) => {
114
+ if (settled) return;
115
+ settled = true;
116
+ clearTimeout(timer);
117
+ if (error) reject(error); else resolve(size!);
118
+ };
119
+
120
+ const timer = setTimeout(
121
+ () => {
122
+ this.client.disconnect();
123
+ finish(new Error(`The host did not present a desktop within ${Math.round(timeoutMs / 1000)}s.`));
124
+ },
125
+ timeoutMs
126
+ );
127
+
128
+ this.client.addEventListener('ready', (event: any) => finish(null, event.detail));
129
+ this.client.addEventListener('close', (event: any) => {
130
+ const detail = event.detail || {};
131
+ finish(new Error(this.lastError || detail.message || 'The connection closed during the handshake.'));
132
+ });
133
+
134
+ // RdpClient opens its transport in its own constructor, so by the time
135
+ // this runs the negotiation is already in flight. Listeners attached
136
+ // here still arrive first: every transport event is asynchronous.
137
+ });
138
+ }
139
+
140
+ disconnect(): void {
141
+ // The client suppresses its own close event on an explicit disconnect, so
142
+ // this is the only place that can answer "is it still connected" honestly.
143
+ this.connected = false;
144
+ this.client.disconnect();
145
+ }
146
+
147
+ // --- input ----------------------------------------------------------------
148
+ //
149
+ // These mirror what the browser's display layer does with a DOM event: the
150
+ // pointer is positioned and the button flag travels with it, and a key is
151
+ // sent by its DOM code because that is what maps to a scancode. The keysym
152
+ // is only the fallback for a character with no key of its own -- the same
153
+ // order of preference the RDP driver uses.
154
+
155
+ get pointer(): PointerState {
156
+ return { ...this.#pointer };
157
+ }
158
+
159
+ movePointer(x: number, y: number): void {
160
+ this.#pointer = this.#clamp(x, y);
161
+ this.client.sendMouseMove(this.#pointer.x, this.#pointer.y);
162
+ }
163
+
164
+ /** `button` is 0 left, 1 middle, 2 right, as the UI numbers them. */
165
+ pointerButton(x: number, y: number, button: number, pressed: boolean): void {
166
+ this.#pointer = this.#clamp(x, y);
167
+ this.client.sendMouseButton(button, pressed, this.#pointer.x, this.#pointer.y);
168
+ }
169
+
170
+ click(x: number, y: number, button = 0): void {
171
+ this.movePointer(x, y);
172
+ this.pointerButton(x, y, button, true);
173
+ this.pointerButton(x, y, button, false);
174
+ }
175
+
176
+ /**
177
+ * Scroll by whole notches, positive being down and right -- the direction
178
+ * the driver interface names.
179
+ *
180
+ * The client's `sendWheel` takes a direction and emits exactly one notch, so
181
+ * a count has to become that many calls. Passing 3 to it would scroll one
182
+ * notch, not three, which is a quiet way to look like scrolling is broken.
183
+ */
184
+ scroll(x: number, y: number, notchesX: number, notchesY: number): void {
185
+ this.#pointer = this.#clamp(x, y);
186
+ const { x: px, y: py } = this.#pointer;
187
+
188
+ for (let i = 0; i < Math.abs(notchesY); i++) {
189
+ this.client.sendWheel(Math.sign(notchesY), px, py, false);
190
+ }
191
+ for (let i = 0; i < Math.abs(notchesX); i++) {
192
+ this.client.sendWheel(Math.sign(notchesX), px, py, true);
193
+ }
194
+ }
195
+
196
+ typeText(text: string): void {
197
+ this.client.sendUnicode(text);
198
+ }
199
+
200
+ /** One key by its DOM code, e.g. `Enter`, `ControlLeft`, `F5`. */
201
+ sendKeyCode(code: string, pressed: boolean): boolean {
202
+ return this.client.sendKeyByCode(code, pressed) !== false;
203
+ }
204
+
205
+ sendCtrlAltDel(): void {
206
+ this.client.sendCtrlAltDel();
207
+ }
208
+
209
+ /** Ask the host to repaint the whole desktop, so a screenshot is complete. */
210
+ requestFullRepaint(): void {
211
+ if (!this.framebuffer.width) return;
212
+ this.client.refresh({
213
+ left: 0,
214
+ top: 0,
215
+ right: this.framebuffer.width - 1,
216
+ bottom: this.framebuffer.height - 1,
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Resolve once the desktop has painted and then gone quiet.
222
+ *
223
+ * A screenshot taken the instant the handshake finishes is black: the host
224
+ * has agreed on a desktop size but has not sent any of it yet. Waiting for a
225
+ * fixed delay instead would be either too short on a busy machine or wasted
226
+ * time on an idle one, so this waits for the thing that actually matters --
227
+ * pixels arriving, and then stopping.
228
+ */
229
+ settle({ quietMs = 500, timeoutMs = 8000 } = {}): Promise<{ painted: boolean; waitedMs: number }> {
230
+ return new Promise((resolve) => {
231
+ const started = Date.now();
232
+ const deadline = started + timeoutMs;
233
+ let seen = -1;
234
+ let quietSince = Date.now();
235
+
236
+ const timer = setInterval(() => {
237
+ const generation = this.framebuffer.generation;
238
+ if (generation !== seen) {
239
+ seen = generation;
240
+ quietSince = Date.now();
241
+ }
242
+
243
+ const painted = this.framebuffer.paints > 0;
244
+ const quiet = Date.now() - quietSince >= quietMs;
245
+ if ((painted && quiet) || Date.now() >= deadline) {
246
+ clearInterval(timer);
247
+ resolve({ painted, waitedMs: Date.now() - started });
248
+ }
249
+ }, 80);
250
+ });
251
+ }
252
+
253
+ setClipboard(text: string): void {
254
+ this.client.setClipboardText(text);
255
+ }
256
+
257
+ #clamp(x: number, y: number): PointerState {
258
+ return {
259
+ x: Math.max(0, Math.min(Math.max(0, this.framebuffer.width - 1), Math.round(x))),
260
+ y: Math.max(0, Math.min(Math.max(0, this.framebuffer.height - 1), Math.round(y))),
261
+ };
262
+ }
263
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * An SSH shell on the same machine the desktop is showing.
3
+ *
4
+ * RDP has no shell channel, so a terminal beside the desktop is a second
5
+ * connection to the same host. The engine is the browser's, unchanged: the key
6
+ * exchange, the session keys and the password exist only in this process.
7
+ */
8
+
9
+ import { SshSession } from '../protocols/ssh/session';
10
+ import { TcpTransport } from './transport';
11
+
12
+ export interface ShellOptions {
13
+ host: string;
14
+ port: number;
15
+ username: string;
16
+ password: string;
17
+ columns?: number;
18
+ rows?: number;
19
+ }
20
+
21
+ export interface CommandResult {
22
+ output: string;
23
+ exitStatus: number | null;
24
+ timedOut: boolean;
25
+ durationMs: number | null;
26
+ }
27
+
28
+ export class Shell {
29
+ readonly options: ShellOptions;
30
+ session: any;
31
+ ready = false;
32
+ lastError = '';
33
+
34
+ #sinks = new Set<(bytes: Uint8Array) => void>();
35
+ /** What the shell has printed, so a viewer attaching late sees the scrollback. */
36
+ #scrollback: Uint8Array[] = [];
37
+ #scrollbackBytes = 0;
38
+
39
+ constructor(options: ShellOptions) {
40
+ this.options = options;
41
+
42
+ this.session = new SshSession(`tcp://${options.host}:${options.port}`, {
43
+ username: options.username,
44
+ password: options.password,
45
+ columns: options.columns ?? 120,
46
+ rows: options.rows ?? 30,
47
+ // Every host this reaches was named by the person running the command,
48
+ // on their own network. There is no stored known_hosts to compare with
49
+ // yet, so nothing useful can be decided here.
50
+ verifyHost: async () => true,
51
+ openTransport: () => new TcpTransport(options.host, options.port),
52
+ });
53
+
54
+ this.session.addEventListener('data', (event: any) => {
55
+ // `display` is what the terminal should show: the same stream with the
56
+ // framing `runCommand` wraps around a command filtered back out. `bytes`
57
+ // is the unfiltered original, which would leak those markers on screen.
58
+ const bytes: Uint8Array = event.detail.display ?? event.detail.bytes;
59
+ if (!bytes?.length) return;
60
+ this.#remember(bytes);
61
+ for (const sink of this.#sinks) sink(bytes);
62
+ });
63
+
64
+ this.session.addEventListener('ready', () => { this.ready = true; });
65
+ this.session.addEventListener('error', (event: any) => {
66
+ this.lastError = event.detail?.message || '';
67
+ });
68
+ this.session.addEventListener('close', () => { this.ready = false; });
69
+ }
70
+
71
+ /** Everything printed so far, capped so a long-running shell cannot grow forever. */
72
+ get scrollback(): Uint8Array {
73
+ const total = this.#scrollback.reduce((sum, chunk) => sum + chunk.length, 0);
74
+ const out = new Uint8Array(total);
75
+ let offset = 0;
76
+ for (const chunk of this.#scrollback) { out.set(chunk, offset); offset += chunk.length; }
77
+ return out;
78
+ }
79
+
80
+ onData(sink: (bytes: Uint8Array) => void): () => void {
81
+ this.#sinks.add(sink);
82
+ return () => this.#sinks.delete(sink);
83
+ }
84
+
85
+ connect(timeoutMs = 30_000): Promise<void> {
86
+ return new Promise((resolve, reject) => {
87
+ let settled = false;
88
+ const finish = (error?: Error) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ clearTimeout(timer);
92
+ if (error) reject(error); else resolve();
93
+ };
94
+
95
+ const timer = setTimeout(
96
+ () => finish(new Error(`The SSH host did not open a shell within ${Math.round(timeoutMs / 1000)}s.`)),
97
+ timeoutMs
98
+ );
99
+
100
+ this.session.addEventListener('ready', () => finish());
101
+ this.session.addEventListener('close', (event: any) => {
102
+ finish(new Error(this.lastError || event.detail?.message || 'The SSH connection closed during sign-in.'));
103
+ });
104
+
105
+ this.session.connect();
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Run one command and wait for its output and exit status.
111
+ *
112
+ * The first command against a Windows host usually fails: the engine frames
113
+ * commands POSIX-style to find where the output starts and ends, cmd.exe
114
+ * rejects that, and the engine notes the shell family and asks to be called
115
+ * again. That is a detail of how the shell was discovered, not something a
116
+ * caller did wrong, so the retry happens here instead of reaching them.
117
+ */
118
+ async runCommand(command: string): Promise<CommandResult> {
119
+ let result;
120
+ try {
121
+ result = await this.session.runCommand(command);
122
+ } catch (error) {
123
+ const message = error instanceof Error ? error.message : String(error);
124
+ if (!/run the command again|not a POSIX shell/i.test(message)) throw error;
125
+
126
+ // Discovering the shell family restarts it, so the retry has to wait for
127
+ // the prompt to come back rather than firing straight into a shell that
128
+ // is still opening.
129
+ await this.#waitForPrompt();
130
+ result = await this.session.runCommand(command);
131
+ }
132
+ return {
133
+ output: result.output ?? '',
134
+ exitStatus: result.exitStatus ?? null,
135
+ timedOut: Boolean(result.timedOut),
136
+ durationMs: result.durationMs ?? null,
137
+ };
138
+ }
139
+
140
+ /** Wait until the shell will accept a framed command again. */
141
+ async #waitForPrompt(timeoutMs = 15_000): Promise<void> {
142
+ const deadline = Date.now() + timeoutMs;
143
+ while (Date.now() < deadline) {
144
+ if (this.session.shellOpen && this.session.readySignaled !== false) return;
145
+ await new Promise((resolve) => setTimeout(resolve, 100));
146
+ }
147
+ throw new Error('The SSH shell did not come back after its family was discovered.');
148
+ }
149
+
150
+ /** Raw keystrokes, for an interactive terminal. */
151
+ write(text: string): void {
152
+ this.session.write(text);
153
+ }
154
+
155
+ resize(columns: number, rows: number): void {
156
+ this.session.resize?.(columns, rows);
157
+ }
158
+
159
+ disconnect(): void {
160
+ try { this.session.disconnect(); } catch { /* already gone */ }
161
+ }
162
+
163
+ #remember(bytes: Uint8Array): void {
164
+ const CAP = 256 * 1024;
165
+ this.#scrollback.push(bytes);
166
+ this.#scrollbackBytes += bytes.length;
167
+ while (this.#scrollbackBytes > CAP && this.#scrollback.length > 1) {
168
+ this.#scrollbackBytes -= this.#scrollback.shift()!.length;
169
+ }
170
+ }
171
+ }