ai-remote 0.5.0 → 0.6.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.
- package/README.md +40 -0
- package/SKILL.md +123 -13
- package/dist/{cli-chunk-Q33YGIDO.mjs → cli-chunk-L6E2YYPQ.mjs} +19 -5
- package/dist/{cli-chunk-F2TSY3YW.mjs → cli-chunk-P4645KKB.mjs} +3 -3
- package/dist/{cli-chunk-OZG2CODR.mjs → cli-chunk-TKKENOSJ.mjs} +1 -1
- package/dist/{cli-chunk-S2TWL5LF.mjs → cli-chunk-UD2YES3P.mjs} +107 -39
- package/dist/cli-chunk-WWORJ2NE.mjs +72 -0
- package/dist/{cli-copy-BOF44DWQ.mjs → cli-copy-2HQU2E7D.mjs} +18 -5
- package/dist/{cli-daemon-3BBFQCML.mjs → cli-daemon-3L36TBIB.mjs} +852 -274
- package/dist/{cli-identities-ZILT4XCR.mjs → cli-identities-GBQWYF2O.mjs} +4 -4
- package/dist/cli-known-hosts-UU427T4N.mjs +12 -0
- package/dist/{cli-secrets-26D3F6EN.mjs → cli-secrets-BMYFPDB2.mjs} +1 -1
- package/dist/cli-shell-JEC6BEME.mjs +11 -0
- package/dist/{cli-window-ZKAXEHQV.mjs → cli-window-AWXZ5NJX.mjs} +2 -2
- package/dist/cli.mjs +219 -109
- package/dist/computer.d.ts +77 -0
- package/dist/computer.js +3 -0
- package/dist/index.d.ts +80 -1
- package/dist/index.js +1 -1
- package/dist/protocols.d.ts +247 -17
- package/dist/protocols.js +16 -16
- package/package.json +6 -1
- package/dist/cli-shell-45XRTE7O.mjs +0 -10
package/README.md
CHANGED
|
@@ -35,6 +35,46 @@ npx ai-remote exec "systemctl status nginx"
|
|
|
35
35
|
npx ai-remote close
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
### Agent computer use over VNC and RDP
|
|
39
|
+
|
|
40
|
+
Open a session once, then reuse it from a persistent Node runtime:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
import { RemoteComputer, desktopPoint } from 'ai-remote/computer';
|
|
44
|
+
|
|
45
|
+
const desktop = new RemoteComputer('my-session');
|
|
46
|
+
const image = await desktop.screenshot({ maxEdge: 1000 });
|
|
47
|
+
// Inspect the PNG in image.base64, then choose a point in screenshot pixels.
|
|
48
|
+
const point = desktopPoint(image, { x: 100, y: 150 });
|
|
49
|
+
const after = await desktop.act([
|
|
50
|
+
{ type: 'click', ...point },
|
|
51
|
+
{ type: 'type', text: 'literal text; including semicolons' },
|
|
52
|
+
]);
|
|
53
|
+
// after.base64 is the new PNG, ready to return to a vision-capable agent.
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The same API drives either protocol. `act` batches input and returns one
|
|
57
|
+
in-memory screenshot; `dispatch` skips capture for a known sequence. Keep the
|
|
58
|
+
JavaScript handle and your own variables across calls. No CLI subprocess or
|
|
59
|
+
new remote connection is needed for each action.
|
|
60
|
+
|
|
61
|
+
For CLI callers, put an action array in a JSON file:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
ai-remote batch actions.json --session my-session --shot after.png --json
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Actions include repeated clicks (`count`, optional `intervalMs`), pointer moves,
|
|
68
|
+
scrolling, literal text, keyboard chords, waits, and drag paths. See
|
|
69
|
+
[the agent skill](SKILL.md#structured-batches-and-a-reusable-javascript-client)
|
|
70
|
+
for schemas, pacing, and coordinate rules. JSON input is validated before
|
|
71
|
+
execution, and desktop IPC batches do not interleave with each other.
|
|
72
|
+
|
|
73
|
+
This supplies remote pixels and input; it does not add browser DOM access,
|
|
74
|
+
native accessibility trees, or a model. `changed` and `quiet` describe observed
|
|
75
|
+
pixels, not completion of the application's work. Verify results after bursts
|
|
76
|
+
and observe before choosing actions that depend on a new screen.
|
|
77
|
+
|
|
38
78
|
### What a command costs, and where the time actually goes
|
|
39
79
|
|
|
40
80
|
Measured on an M4, `list` with two sessions open:
|
package/SKILL.md
CHANGED
|
@@ -53,25 +53,104 @@ prints are npm's own and harmless — ignore them, or add `--silent`.
|
|
|
53
53
|
|
|
54
54
|
## Working loop
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
### Structured batches and a reusable JavaScript client
|
|
57
|
+
|
|
58
|
+
Prefer `batch` for generated actions and literal text. Unlike `do`, JSON text
|
|
59
|
+
is never split on semicolons, and the entire batch schema and coordinates are
|
|
60
|
+
validated before input starts. Both VNC and RDP use the same action API.
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
[
|
|
64
|
+
{ "type": "click", "x": 168, "y": 185, "count": 50 },
|
|
65
|
+
{ "type": "wait", "ms": 500 }
|
|
66
|
+
]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Save this as `actions.json`, then run:
|
|
58
70
|
|
|
59
71
|
```bash
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
ai-remote batch actions.json --session test --shot result.png --region 8,95,320,180 --json
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`batch -` reads JSON from stdin. Without `--shot`, dispatch finishes without
|
|
76
|
+
waiting for a screenshot. A click's optional `intervalMs` controls pacing;
|
|
77
|
+
its default is zero. Fifty inputs dispatched is not proof of fifty inputs
|
|
78
|
+
processed: verify the application's resulting count. A burst may still be
|
|
79
|
+
drawing when the first screenshot arrives. Add a workload-specific `wait`
|
|
80
|
+
inside the batch or take another observation when that happens.
|
|
81
|
+
|
|
82
|
+
Supported actions: `click` (`x`, `y`, optional `button`, `count`, `intervalMs`),
|
|
83
|
+
`move` (`x`, `y`), `scroll` (`x`, `y`, `dx`, `dy`), `type` (`text`, optional
|
|
84
|
+
`chunkSize`, `intervalMs`), `key` (`chord`), `wait` (`ms`), and `drag` (`path`
|
|
85
|
+
of `{x,y}` points, optional `button`, `intervalMs`). Buttons are left=0,
|
|
86
|
+
middle=1, right=2. Drag releases its button even if a step fails. Text defaults
|
|
87
|
+
to eight Unicode code points per chunk with 20 ms between chunks; zero pacing
|
|
88
|
+
can be selected explicitly. This avoids the observed unpaced long-input issue
|
|
89
|
+
in the RDP Chrome omnibox without slowing clicks.
|
|
90
|
+
|
|
91
|
+
For a persistent Node/JavaScript execution environment:
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
import { RemoteComputer, desktopPoint } from 'ai-remote/computer';
|
|
95
|
+
const desktop = new RemoteComputer('test'); // session already opened by CLI
|
|
96
|
+
const image = await desktop.screenshot({ maxEdge: 800 });
|
|
97
|
+
// Inspect image.base64 as PNG before choosing a point in that image.
|
|
98
|
+
const point = desktopPoint(image, { x: 100, y: 100 });
|
|
99
|
+
const after = await desktop.act([{ type: 'click', ...point }]);
|
|
100
|
+
// Return after.base64 directly to the vision-capable caller.
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Keep `desktop` and variables in the caller's JavaScript runtime. `act` returns
|
|
104
|
+
PNG base64 and geometry directly, with no screenshot file or CLI subprocess.
|
|
105
|
+
`dispatch` takes the same actions and skips observation. The remote connection
|
|
106
|
+
stays in the existing daemon; individual API calls use local IPC. Desktop IPC
|
|
107
|
+
batches and screenshots are serialized; a live human/viewer can still interact.
|
|
108
|
+
This is a pixel/input API, not a browser DOM or native accessibility tree.
|
|
109
|
+
|
|
110
|
+
Act, then look, in one command. `--shot` runs the steps and captures the frame
|
|
111
|
+
they produced, so the two halves of one observation cost one round trip:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npx ai-remote key MetaLeft --shot after.png # open the Start menu, and see it
|
|
115
|
+
npx ai-remote click 640,400 --shot after.png
|
|
116
|
+
npx ai-remote do "type hello; key Enter" --shot after.png
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The capture waits for the frame the steps caused, not for a fixed delay, and it
|
|
120
|
+
tells you what it found:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
Pressed MetaLeft. Wrote after.png (1280x800).
|
|
124
|
+
Pressed MetaLeft. Wrote after.png (1280x800), though nothing on screen changed.
|
|
62
125
|
```
|
|
63
126
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
127
|
+
**Read that second line.** "Nothing on screen changed" usually means the click
|
|
128
|
+
landed somewhere that does nothing — the wrong coordinates, a window that was
|
|
129
|
+
not focused — rather than that the screenshot is stale. Look again before
|
|
130
|
+
repeating the click.
|
|
131
|
+
|
|
132
|
+
It can also mean the action's only mark was too small to tell apart from what
|
|
133
|
+
the desktop does on its own: clicking into a text field paints one caret, and a
|
|
134
|
+
caret is smaller than a clock ticking. The picture is still the current one.
|
|
135
|
+
|
|
136
|
+
"The screen was still moving" is not a failure: the picture is current, it was
|
|
137
|
+
just taken while something was animating. Take another if it matters.
|
|
138
|
+
|
|
139
|
+
A bare `npx ai-remote shot -o after.png` still works and is the right thing when
|
|
140
|
+
you have not just done something. When an action starts something slow — an app
|
|
141
|
+
launching, a page loading — put an explicit wait in a `do` script rather than
|
|
142
|
+
guessing from outside:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
npx ai-remote do "key MetaLeft; wait 800; type notepad; key Enter; wait 1500" --shot s.png
|
|
146
|
+
```
|
|
68
147
|
|
|
69
148
|
Prefer `do` when the next few steps are already known — one round trip instead
|
|
70
149
|
of four, and the timing between steps stays inside the session rather than
|
|
71
150
|
depending on how fast the shell gets round to the next command:
|
|
72
151
|
|
|
73
152
|
```bash
|
|
74
|
-
npx ai-remote do "key MetaLeft; wait 800; type notepad; wait 1200; key Enter; wait 1500
|
|
153
|
+
npx ai-remote do "key MetaLeft; wait 800; type notepad; wait 1200; key Enter; wait 1500" --shot s.png
|
|
75
154
|
```
|
|
76
155
|
|
|
77
156
|
Steps, separated by `;`:
|
|
@@ -114,6 +193,34 @@ screenshot full size and click the coordinates you read off it.
|
|
|
114
193
|
image tokens and a 800px one is a fraction of that, usually with no loss of
|
|
115
194
|
anything you needed to see.
|
|
116
195
|
|
|
196
|
+
### Focus on one application
|
|
197
|
+
|
|
198
|
+
For a known window that stays in place, `--region X,Y,W,H` captures only that
|
|
199
|
+
rectangle. It works on `shot` and on commands with `--shot`. Use `--json` to
|
|
200
|
+
retain its coordinate origin:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
ai-remote click 544,705 --shot result.png --region 520,538,208,351 --json
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Coordinates sent to input remain full-desktop pixels. For a returned cropped
|
|
207
|
+
image, map using `desktop_x = viewport.x + image_x * viewport.width / width`
|
|
208
|
+
and the equivalent formula for y. `width` and `height` are the returned image
|
|
209
|
+
size; `desktopWidth` and `desktopHeight` are the whole desktop. The full-screen
|
|
210
|
+
scaling formula above does not apply to cropped images.
|
|
211
|
+
|
|
212
|
+
Invalid or out-of-bounds regions are refused before an atomic action runs.
|
|
213
|
+
Re-observe the full desktop if the window moves or a dialog can appear outside
|
|
214
|
+
the crop. A crop is explicit geometry, not automatic window tracking.
|
|
215
|
+
|
|
216
|
+
A region also says which part of the screen you care about, so the wait for the
|
|
217
|
+
screen to settle watches that rectangle and ignores the rest. On a machine with
|
|
218
|
+
a build log scrolling in one corner, that is the difference between a capture
|
|
219
|
+
that returns promptly and one that waits for a screen that is never still. The
|
|
220
|
+
trade is the other way round too: a change *outside* the region no longer holds
|
|
221
|
+
the capture back, so re-observe the whole desktop when something may have
|
|
222
|
+
happened elsewhere.
|
|
223
|
+
|
|
117
224
|
## Modifier keys between a Mac and a PC
|
|
118
225
|
|
|
119
226
|
When one end is a Mac and the other is not, **Control and Command swap** so the
|
|
@@ -433,10 +540,13 @@ npx ai-remote open HOST --ssh -u USER [-i ~/.ssh/id_ed25519] # a terminal,
|
|
|
433
540
|
npx ai-remote open HOST --vnc -u USER # a screen over VNC
|
|
434
541
|
npx ai-remote vnc [--vnc-port N] # ...or add one to this session
|
|
435
542
|
npx ai-remote shot [-o FILE] [--max-edge N] [--json]
|
|
436
|
-
npx ai-remote click X,Y [--button right|middle] [--double]
|
|
437
|
-
npx ai-remote type "text"
|
|
438
|
-
npx ai-remote key ControlLeft+KeyA
|
|
439
|
-
npx ai-remote do "key MetaLeft; wait 800; type notepad
|
|
543
|
+
npx ai-remote click X,Y [--button right|middle] [--double] [--shot FILE]
|
|
544
|
+
npx ai-remote type "text" [--shot FILE]
|
|
545
|
+
npx ai-remote key ControlLeft+KeyA [--shot FILE]
|
|
546
|
+
npx ai-remote do "key MetaLeft; wait 800; type notepad" [--shot FILE]
|
|
547
|
+
# --shot: act and look in one round trip
|
|
548
|
+
# bare --shot writes screen.png; a name that is
|
|
549
|
+
# not a .png goes as --shot=NAME
|
|
440
550
|
npx ai-remote exec "command" [--reconnect]
|
|
441
551
|
npx ai-remote reconnect # a new login: fresh PATH
|
|
442
552
|
npx ai-remote clipboard [on|off] # share text both ways
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
SshSession,
|
|
3
3
|
TcpTransport
|
|
4
|
-
} from "./cli-chunk-
|
|
4
|
+
} from "./cli-chunk-UD2YES3P.mjs";
|
|
5
|
+
import {
|
|
6
|
+
checkHostKey,
|
|
7
|
+
hostAddress
|
|
8
|
+
} from "./cli-chunk-WWORJ2NE.mjs";
|
|
5
9
|
|
|
6
10
|
// src/cli/shell.ts
|
|
7
11
|
function bareUsername(username) {
|
|
@@ -27,10 +31,20 @@ var Shell = class {
|
|
|
27
31
|
identities: options.identities ?? [],
|
|
28
32
|
columns: options.columns ?? 120,
|
|
29
33
|
rows: options.rows ?? 30,
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
// Compared against `~/.config/ai-remote/known_hosts.json`, on the same
|
|
35
|
+
// accept-new terms OpenSSH uses. A key that changed stops the connection.
|
|
36
|
+
verifyHost: (info) => {
|
|
37
|
+
const verdict = checkHostKey(
|
|
38
|
+
hostAddress(options.host, options.port),
|
|
39
|
+
info,
|
|
40
|
+
{ insecure: options.insecureHostKey }
|
|
41
|
+
);
|
|
42
|
+
if (verdict.message) {
|
|
43
|
+
(options.onNotice ?? ((line) => console.error(line)))(verdict.message);
|
|
44
|
+
}
|
|
45
|
+
if (!verdict.accepted) this.lastError = verdict.message;
|
|
46
|
+
return verdict.accepted;
|
|
47
|
+
},
|
|
34
48
|
openTransport: () => new TcpTransport(options.host, options.port)
|
|
35
49
|
});
|
|
36
50
|
this.session.addEventListener("data", (event) => {
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
VERSION,
|
|
4
4
|
isTitlebarDragPoint,
|
|
5
5
|
logicalDisplaySize
|
|
6
|
-
} from "./cli-chunk-
|
|
6
|
+
} from "./cli-chunk-TKKENOSJ.mjs";
|
|
7
7
|
|
|
8
8
|
// src/cli/window.ts
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
@@ -38,7 +38,7 @@ function chunk(type, body) {
|
|
|
38
38
|
view.setUint32(8 + body.length, crc32(out.subarray(4, 8 + body.length)));
|
|
39
39
|
return out;
|
|
40
40
|
}
|
|
41
|
-
function encodePng(rgba, width, height) {
|
|
41
|
+
function encodePng(rgba, width, height, level = 6) {
|
|
42
42
|
const stride = width * 4;
|
|
43
43
|
const raw = new Uint8Array((stride + 1) * height);
|
|
44
44
|
for (let y = 0; y < height; y++) {
|
|
@@ -57,7 +57,7 @@ function encodePng(rgba, width, height) {
|
|
|
57
57
|
const parts = [
|
|
58
58
|
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
59
59
|
chunk("IHDR", ihdr),
|
|
60
|
-
chunk("IDAT", new Uint8Array(deflateSync(raw, { level
|
|
60
|
+
chunk("IDAT", new Uint8Array(deflateSync(raw, { level }))),
|
|
61
61
|
chunk("IEND", new Uint8Array(0))
|
|
62
62
|
];
|
|
63
63
|
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
@@ -175,7 +175,9 @@ async function createKexClient(algorithmName) {
|
|
|
175
175
|
}
|
|
176
176
|
};
|
|
177
177
|
}
|
|
178
|
-
|
|
178
|
+
if (!algorithm.curve) throw new Error(`SSH: ${algorithmName} names no curve to exchange over`);
|
|
179
|
+
const curve = algorithm.curve;
|
|
180
|
+
const pair = await subtle.generateKey({ name: "ECDH", namedCurve: curve }, false, ["deriveBits"]);
|
|
179
181
|
const publicKey = new Uint8Array(await subtle.exportKey("raw", pair.publicKey));
|
|
180
182
|
return {
|
|
181
183
|
hash: algorithm.hash,
|
|
@@ -184,7 +186,7 @@ async function createKexClient(algorithmName) {
|
|
|
184
186
|
const peer = await subtle.importKey(
|
|
185
187
|
"raw",
|
|
186
188
|
serverPublicKey,
|
|
187
|
-
{ name: "ECDH", namedCurve:
|
|
189
|
+
{ name: "ECDH", namedCurve: curve },
|
|
188
190
|
false,
|
|
189
191
|
[]
|
|
190
192
|
);
|
|
@@ -243,12 +245,14 @@ function signatureBytes(algorithmName, signatureBlob) {
|
|
|
243
245
|
const s = parts.mpint();
|
|
244
246
|
return concatBytes(padStart(r, 32), padStart(s, 32));
|
|
245
247
|
}
|
|
246
|
-
async function verifyHostKeySignature({ algorithm, keyBlob, signatureBlob, exchangeHash:
|
|
247
|
-
const
|
|
248
|
+
async function verifyHostKeySignature({ algorithm, keyBlob, signatureBlob, exchangeHash: signedHash }) {
|
|
249
|
+
const named = HOST_KEY_ALGORITHMS[algorithm];
|
|
250
|
+
if (!named) throw new Error(`SSH: unsupported host key algorithm ${algorithm}`);
|
|
251
|
+
const { kind, hash } = named;
|
|
248
252
|
const key = await importHostKey(algorithm, keyBlob);
|
|
249
253
|
const signature = signatureBytes(algorithm, signatureBlob);
|
|
250
254
|
const parameters = kind === "ecdsa" ? { name: "ECDSA", hash } : kind === "rsa" ? { name: "RSASSA-PKCS1-v1_5" } : { name: "Ed25519" };
|
|
251
|
-
return subtle.verify(parameters, key, signature,
|
|
255
|
+
return subtle.verify(parameters, key, signature, signedHash);
|
|
252
256
|
}
|
|
253
257
|
async function exchangeHash({
|
|
254
258
|
hash,
|
|
@@ -343,9 +347,11 @@ var SshTransport = class extends EventTarget {
|
|
|
343
347
|
greeting;
|
|
344
348
|
sendSequence;
|
|
345
349
|
receiveSequence;
|
|
350
|
+
/** The AES-GCM state for each direction, once NEWKEYS has been exchanged. */
|
|
346
351
|
outgoing;
|
|
347
352
|
incoming;
|
|
348
353
|
sessionId;
|
|
354
|
+
/** The key exchange in flight, or null between exchanges. */
|
|
349
355
|
kex;
|
|
350
356
|
kexQueue;
|
|
351
357
|
keepaliveTimer;
|
|
@@ -356,6 +362,7 @@ var SshTransport = class extends EventTarget {
|
|
|
356
362
|
releaseNewKeys;
|
|
357
363
|
pendingServerKexInit;
|
|
358
364
|
established;
|
|
365
|
+
drainChain;
|
|
359
366
|
/**
|
|
360
367
|
* @param {string} url gateway WebSocket URL
|
|
361
368
|
* @param {object} options
|
|
@@ -381,16 +388,17 @@ var SshTransport = class extends EventTarget {
|
|
|
381
388
|
this.sessionId = null;
|
|
382
389
|
this.kex = null;
|
|
383
390
|
this.kexQueue = [];
|
|
384
|
-
this.keepaliveTimer =
|
|
391
|
+
this.keepaliveTimer = void 0;
|
|
385
392
|
this.bytesSent = 0;
|
|
386
393
|
this.bytesReceived = 0;
|
|
387
394
|
}
|
|
388
395
|
// --- connection ----------------------------------------------------------
|
|
389
396
|
connect() {
|
|
390
|
-
|
|
391
|
-
this.socket
|
|
392
|
-
|
|
393
|
-
|
|
397
|
+
const socket = this.openTransport ? this.openTransport(this.url) : new WebSocket(this.url);
|
|
398
|
+
this.socket = socket;
|
|
399
|
+
socket.binaryType = "arraybuffer";
|
|
400
|
+
socket.addEventListener("open", () => {
|
|
401
|
+
socket.send(GATEWAY_READY_SIGNAL);
|
|
394
402
|
this.#write(encodeUtf8(`${CLIENT_VERSION}\r
|
|
395
403
|
`));
|
|
396
404
|
this.keepaliveTimer = setInterval(() => {
|
|
@@ -404,18 +412,20 @@ var SshTransport = class extends EventTarget {
|
|
|
404
412
|
}
|
|
405
413
|
}, KEEPALIVE_INTERVAL_MS);
|
|
406
414
|
});
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
415
|
+
socket.addEventListener("message", (event) => {
|
|
416
|
+
const { data } = event;
|
|
417
|
+
if (typeof data === "string") return;
|
|
418
|
+
this.#receive(new Uint8Array(data));
|
|
410
419
|
});
|
|
411
|
-
|
|
420
|
+
socket.addEventListener("close", (rawEvent) => {
|
|
421
|
+
const event = rawEvent;
|
|
412
422
|
this.#finish({
|
|
413
423
|
clean: event.code === 1e3,
|
|
414
424
|
code: event.code,
|
|
415
425
|
message: event.reason || (event.code === 1006 ? "The gateway rejected or could not reach this SSH target. Confirm that SSH is published for this host." : "")
|
|
416
426
|
});
|
|
417
427
|
});
|
|
418
|
-
|
|
428
|
+
socket.addEventListener("error", () => {
|
|
419
429
|
this.log("The SSH gateway WebSocket reported a transport error; waiting for its close reason.");
|
|
420
430
|
});
|
|
421
431
|
}
|
|
@@ -439,7 +449,7 @@ var SshTransport = class extends EventTarget {
|
|
|
439
449
|
if (this.closed) return;
|
|
440
450
|
this.closed = true;
|
|
441
451
|
clearInterval(this.keepaliveTimer);
|
|
442
|
-
this.keepaliveTimer =
|
|
452
|
+
this.keepaliveTimer = void 0;
|
|
443
453
|
this.dispatchEvent(new CustomEvent("close", { detail }));
|
|
444
454
|
}
|
|
445
455
|
fail(error) {
|
|
@@ -550,16 +560,18 @@ var SshTransport = class extends EventTarget {
|
|
|
550
560
|
return frame;
|
|
551
561
|
}
|
|
552
562
|
async #decrypt(frame) {
|
|
563
|
+
const incoming = this.incoming;
|
|
564
|
+
if (!incoming) throw new Error("SSH: a packet arrived before the cipher was installed.");
|
|
553
565
|
let plain;
|
|
554
566
|
try {
|
|
555
567
|
plain = new Uint8Array(await subtle2.decrypt(
|
|
556
568
|
{
|
|
557
569
|
name: "AES-GCM",
|
|
558
|
-
iv:
|
|
570
|
+
iv: incoming.nextIv(),
|
|
559
571
|
additionalData: frame.subarray(0, 4),
|
|
560
572
|
tagLength: GCM_TAG_BYTES * 8
|
|
561
573
|
},
|
|
562
|
-
|
|
574
|
+
incoming.key,
|
|
563
575
|
frame.subarray(4)
|
|
564
576
|
));
|
|
565
577
|
} catch {
|
|
@@ -711,6 +723,8 @@ var SshTransport = class extends EventTarget {
|
|
|
711
723
|
this.kex.inbox.push(payload);
|
|
712
724
|
}
|
|
713
725
|
#nextKexPacket(expectedType) {
|
|
726
|
+
const kex = this.kex;
|
|
727
|
+
if (!kex) throw new Error("SSH: a key-exchange packet was awaited outside a key exchange.");
|
|
714
728
|
return new Promise((resolve, reject) => {
|
|
715
729
|
const check = (payload) => {
|
|
716
730
|
if (expectedType !== void 0 && payload[0] !== expectedType) {
|
|
@@ -719,13 +733,13 @@ var SshTransport = class extends EventTarget {
|
|
|
719
733
|
}
|
|
720
734
|
resolve(payload);
|
|
721
735
|
};
|
|
722
|
-
const queued =
|
|
736
|
+
const queued = kex.inbox.shift();
|
|
723
737
|
if (queued) {
|
|
724
738
|
check(queued);
|
|
725
739
|
return;
|
|
726
740
|
}
|
|
727
|
-
|
|
728
|
-
|
|
741
|
+
kex.pending = (payload) => {
|
|
742
|
+
kex.pending = null;
|
|
729
743
|
check(payload);
|
|
730
744
|
};
|
|
731
745
|
});
|
|
@@ -792,15 +806,20 @@ var SshTransport = class extends EventTarget {
|
|
|
792
806
|
exchangeHash: hash,
|
|
793
807
|
sessionId: this.sessionId
|
|
794
808
|
};
|
|
809
|
+
const toServerCipher = CIPHERS[cipherToServer];
|
|
810
|
+
const toClientCipher = CIPHERS[cipherToClient];
|
|
811
|
+
if (!toServerCipher || !toClientCipher) {
|
|
812
|
+
throw new Error(`SSH: negotiated a cipher this client does not implement (${cipherToServer}, ${cipherToClient})`);
|
|
813
|
+
}
|
|
795
814
|
const [ivToServer, ivToClient, keyToServer, keyToClient] = await Promise.all([
|
|
796
815
|
deriveKey({ ...parameters, letter: "A", length: GCM_IV_BYTES }),
|
|
797
816
|
deriveKey({ ...parameters, letter: "B", length: GCM_IV_BYTES }),
|
|
798
|
-
deriveKey({ ...parameters, letter: "C", length:
|
|
799
|
-
deriveKey({ ...parameters, letter: "D", length:
|
|
817
|
+
deriveKey({ ...parameters, letter: "C", length: toServerCipher.keyLength }),
|
|
818
|
+
deriveKey({ ...parameters, letter: "D", length: toClientCipher.keyLength })
|
|
800
819
|
]);
|
|
801
820
|
this.outgoing = await GcmCipher.create(keyToServer, ivToServer);
|
|
802
821
|
this.incoming = await GcmCipher.create(keyToClient, ivToClient);
|
|
803
|
-
this.kex.running = false;
|
|
822
|
+
if (this.kex) this.kex.running = false;
|
|
804
823
|
this.releaseNewKeys?.();
|
|
805
824
|
this.releaseNewKeys = null;
|
|
806
825
|
const queued = this.kexQueue;
|
|
@@ -956,6 +975,55 @@ function releaseDisplayFilter(session) {
|
|
|
956
975
|
}));
|
|
957
976
|
}
|
|
958
977
|
var SshSession = class extends EventTarget {
|
|
978
|
+
options;
|
|
979
|
+
username;
|
|
980
|
+
password;
|
|
981
|
+
/**
|
|
982
|
+
* Keys to sign in with, if any. A function rather than a list is allowed
|
|
983
|
+
* because reading keys off a disk -- or asking an agent for them -- is work
|
|
984
|
+
* a session that never needs them should not do.
|
|
985
|
+
*/
|
|
986
|
+
/** Replaced with the resolved list the first time it is read. */
|
|
987
|
+
identities;
|
|
988
|
+
requestInput;
|
|
989
|
+
/** Whether there is anybody to ask; see the constructor. */
|
|
990
|
+
canAsk;
|
|
991
|
+
columns;
|
|
992
|
+
rows;
|
|
993
|
+
/** A subsystem to run instead of a shell, e.g. 'sftp'. */
|
|
994
|
+
subsystem;
|
|
995
|
+
channelId;
|
|
996
|
+
remoteChannelId;
|
|
997
|
+
remoteWindow;
|
|
998
|
+
remoteMaxPacket;
|
|
999
|
+
localWindow;
|
|
1000
|
+
pendingWrites;
|
|
1001
|
+
/** True while the interactive shell channel is usable. */
|
|
1002
|
+
shellOpen;
|
|
1003
|
+
commandInFlight;
|
|
1004
|
+
/** Set while a tool command runs, to keep its framing off the screen. */
|
|
1005
|
+
displayFilter;
|
|
1006
|
+
/** True only after the requested interactive shell is ready for commands. */
|
|
1007
|
+
/** True once the interactive shell is actually usable, not merely authenticated. */
|
|
1008
|
+
readySignaled = false;
|
|
1009
|
+
powerShellAttempted;
|
|
1010
|
+
powerShellTimer;
|
|
1011
|
+
/** '', 'posix', 'cmd' or 'powershell'; learned from the prompt. */
|
|
1012
|
+
/** 'powershell' when the host answered with one; '' until the shell is known. */
|
|
1013
|
+
shellFamily = "";
|
|
1014
|
+
promptTail;
|
|
1015
|
+
exitStatus;
|
|
1016
|
+
exitSignal;
|
|
1017
|
+
authMethods;
|
|
1018
|
+
/** Which keys were offered and refused, for the message if nothing works. */
|
|
1019
|
+
triedKeys;
|
|
1020
|
+
/** Set once the host has said no to the stored password. */
|
|
1021
|
+
passwordRejected;
|
|
1022
|
+
closed;
|
|
1023
|
+
waiters;
|
|
1024
|
+
transport;
|
|
1025
|
+
fingerprint;
|
|
1026
|
+
lastMessage;
|
|
959
1027
|
/**
|
|
960
1028
|
* @param {string} url gateway WebSocket URL
|
|
961
1029
|
* @param {object} options
|
|
@@ -1033,7 +1101,7 @@ var SshSession = class extends EventTarget {
|
|
|
1033
1101
|
this.transport.addEventListener("close", (event) => {
|
|
1034
1102
|
if (this.closed) return;
|
|
1035
1103
|
this.closed = true;
|
|
1036
|
-
clearTimeout(this.powerShellTimer);
|
|
1104
|
+
clearTimeout(this.powerShellTimer ?? void 0);
|
|
1037
1105
|
this.#abandonWaiters(event.detail.message || "The SSH connection closed.");
|
|
1038
1106
|
this.dispatchEvent(new CustomEvent("close", {
|
|
1039
1107
|
detail: {
|
|
@@ -1050,7 +1118,7 @@ var SshSession = class extends EventTarget {
|
|
|
1050
1118
|
}
|
|
1051
1119
|
disconnect() {
|
|
1052
1120
|
if (this.closed) return;
|
|
1053
|
-
clearTimeout(this.powerShellTimer);
|
|
1121
|
+
clearTimeout(this.powerShellTimer ?? void 0);
|
|
1054
1122
|
if (this.shellOpen && this.remoteChannelId !== null) {
|
|
1055
1123
|
this.transport.send(new SshWriter(8).u8(MSG.CHANNEL_CLOSE).u32(this.remoteChannelId).take());
|
|
1056
1124
|
}
|
|
@@ -1086,7 +1154,7 @@ var SshSession = class extends EventTarget {
|
|
|
1086
1154
|
idleMs = COMMAND_IDLE_MS,
|
|
1087
1155
|
maxMs = COMMAND_MAX_MS
|
|
1088
1156
|
} = {}) {
|
|
1089
|
-
const text =
|
|
1157
|
+
const text = (command || "").trim();
|
|
1090
1158
|
if (!text) return Promise.reject(new Error("No command was given."));
|
|
1091
1159
|
if (!this.shellOpen || this.closed) {
|
|
1092
1160
|
return Promise.reject(new Error("The SSH shell is not open."));
|
|
@@ -1111,9 +1179,9 @@ var SshSession = class extends EventTarget {
|
|
|
1111
1179
|
return new Promise((resolve, reject) => {
|
|
1112
1180
|
let transcript = "";
|
|
1113
1181
|
let started = false;
|
|
1114
|
-
let startTimer
|
|
1115
|
-
let idleTimer
|
|
1116
|
-
let maxTimer
|
|
1182
|
+
let startTimer;
|
|
1183
|
+
let idleTimer;
|
|
1184
|
+
let maxTimer;
|
|
1117
1185
|
const finish = (settle) => {
|
|
1118
1186
|
clearTimeout(startTimer);
|
|
1119
1187
|
clearTimeout(idleTimer);
|
|
@@ -1149,7 +1217,7 @@ var SshSession = class extends EventTarget {
|
|
|
1149
1217
|
timedOut: false,
|
|
1150
1218
|
started,
|
|
1151
1219
|
durationMs: Date.now() - startedAt,
|
|
1152
|
-
error: error.message
|
|
1220
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1153
1221
|
}
|
|
1154
1222
|
}));
|
|
1155
1223
|
reject(error);
|
|
@@ -1174,7 +1242,7 @@ var SshSession = class extends EventTarget {
|
|
|
1174
1242
|
if (!started && transcript.indexOf(begin) !== -1) {
|
|
1175
1243
|
started = true;
|
|
1176
1244
|
clearTimeout(startTimer);
|
|
1177
|
-
startTimer =
|
|
1245
|
+
startTimer = void 0;
|
|
1178
1246
|
}
|
|
1179
1247
|
if (started) {
|
|
1180
1248
|
clearTimeout(idleTimer);
|
|
@@ -1333,7 +1401,7 @@ var SshSession = class extends EventTarget {
|
|
|
1333
1401
|
}, 2e3);
|
|
1334
1402
|
}
|
|
1335
1403
|
#markReady(message = "Connected.") {
|
|
1336
|
-
clearTimeout(this.powerShellTimer);
|
|
1404
|
+
clearTimeout(this.powerShellTimer ?? void 0);
|
|
1337
1405
|
this.powerShellTimer = null;
|
|
1338
1406
|
this.readySignaled = true;
|
|
1339
1407
|
this.#status("ready", message);
|
|
@@ -1373,21 +1441,21 @@ var SshSession = class extends EventTarget {
|
|
|
1373
1441
|
this.#status("authenticating", `Authenticating as ${this.username}\u2026`);
|
|
1374
1442
|
this.transport.send(new SshWriter(32).u8(MSG.SERVICE_REQUEST).string("ssh-userauth").take());
|
|
1375
1443
|
await this.#expect(MSG.SERVICE_ACCEPT);
|
|
1376
|
-
if (await this.#tryNone()) return
|
|
1444
|
+
if (await this.#tryNone()) return await this.#openShell();
|
|
1377
1445
|
if (this.authMethods.includes("publickey")) {
|
|
1378
1446
|
for (const identity of await this.#identities()) {
|
|
1379
|
-
if (await this.#tryPublicKey(identity)) return
|
|
1447
|
+
if (await this.#tryPublicKey(identity)) return await this.#openShell();
|
|
1380
1448
|
}
|
|
1381
1449
|
}
|
|
1382
1450
|
if (this.authMethods.includes("password") && this.password) {
|
|
1383
|
-
if (await this.#tryPassword(this.password)) return
|
|
1451
|
+
if (await this.#tryPassword(this.password)) return await this.#openShell();
|
|
1384
1452
|
this.passwordRejected = true;
|
|
1385
1453
|
}
|
|
1386
1454
|
if (this.authMethods.includes("keyboard-interactive")) {
|
|
1387
|
-
if (await this.#tryKeyboardInteractive()) return
|
|
1455
|
+
if (await this.#tryKeyboardInteractive()) return await this.#openShell();
|
|
1388
1456
|
if (this.canAsk && this.authMethods.includes("keyboard-interactive")) {
|
|
1389
1457
|
if (await this.#tryKeyboardInteractive({ usePassword: false })) {
|
|
1390
|
-
return
|
|
1458
|
+
return await this.#openShell();
|
|
1391
1459
|
}
|
|
1392
1460
|
}
|
|
1393
1461
|
}
|
|
@@ -1396,7 +1464,7 @@ var SshSession = class extends EventTarget {
|
|
|
1396
1464
|
prompt: `${this.username}@${this.options.hostLabel || "host"}'s password: `,
|
|
1397
1465
|
echo: false
|
|
1398
1466
|
});
|
|
1399
|
-
if (typed && await this.#tryPassword(typed)) return
|
|
1467
|
+
if (typed && await this.#tryPassword(typed)) return await this.#openShell();
|
|
1400
1468
|
}
|
|
1401
1469
|
throw new Error(this.authMethods.length ? `SSH: authentication failed${this.triedKeys.length ? ` (the host refused ${this.triedKeys.join(", ")})` : ""}. The host accepts ${this.authMethods.join(", ")}.` : "SSH: the host rejected the connection before any authentication method was offered.");
|
|
1402
1470
|
} catch (error) {
|