ai-remote 0.5.1 → 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-M5GGPPAC.mjs → cli-chunk-L6E2YYPQ.mjs} +19 -5
- package/dist/{cli-chunk-DGYNADUR.mjs → cli-chunk-P4645KKB.mjs} +3 -3
- package/dist/{cli-chunk-WBOYG4KJ.mjs → cli-chunk-TKKENOSJ.mjs} +1 -1
- package/dist/{cli-chunk-X2JMVOWL.mjs → cli-chunk-UD2YES3P.mjs} +33 -22
- package/dist/cli-chunk-WWORJ2NE.mjs +72 -0
- package/dist/{cli-copy-ZBTICP7N.mjs → cli-copy-2HQU2E7D.mjs} +18 -5
- package/dist/{cli-daemon-M3KM2YHW.mjs → cli-daemon-3L36TBIB.mjs} +636 -211
- 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-WLRFUKSW.mjs → cli-window-AWXZ5NJX.mjs} +2 -2
- package/dist/cli.mjs +201 -95
- 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 +108 -12
- package/dist/protocols.js +15 -15
- package/package.json +6 -1
- package/dist/cli-shell-22PZYNSJ.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);
|
|
@@ -245,14 +245,14 @@ function signatureBytes(algorithmName, signatureBlob) {
|
|
|
245
245
|
const s = parts.mpint();
|
|
246
246
|
return concatBytes(padStart(r, 32), padStart(s, 32));
|
|
247
247
|
}
|
|
248
|
-
async function verifyHostKeySignature({ algorithm, keyBlob, signatureBlob, exchangeHash:
|
|
248
|
+
async function verifyHostKeySignature({ algorithm, keyBlob, signatureBlob, exchangeHash: signedHash }) {
|
|
249
249
|
const named = HOST_KEY_ALGORITHMS[algorithm];
|
|
250
250
|
if (!named) throw new Error(`SSH: unsupported host key algorithm ${algorithm}`);
|
|
251
251
|
const { kind, hash } = named;
|
|
252
252
|
const key = await importHostKey(algorithm, keyBlob);
|
|
253
253
|
const signature = signatureBytes(algorithm, signatureBlob);
|
|
254
254
|
const parameters = kind === "ecdsa" ? { name: "ECDSA", hash } : kind === "rsa" ? { name: "RSASSA-PKCS1-v1_5" } : { name: "Ed25519" };
|
|
255
|
-
return subtle.verify(parameters, key, signature,
|
|
255
|
+
return subtle.verify(parameters, key, signature, signedHash);
|
|
256
256
|
}
|
|
257
257
|
async function exchangeHash({
|
|
258
258
|
hash,
|
|
@@ -347,9 +347,11 @@ var SshTransport = class extends EventTarget {
|
|
|
347
347
|
greeting;
|
|
348
348
|
sendSequence;
|
|
349
349
|
receiveSequence;
|
|
350
|
+
/** The AES-GCM state for each direction, once NEWKEYS has been exchanged. */
|
|
350
351
|
outgoing;
|
|
351
352
|
incoming;
|
|
352
353
|
sessionId;
|
|
354
|
+
/** The key exchange in flight, or null between exchanges. */
|
|
353
355
|
kex;
|
|
354
356
|
kexQueue;
|
|
355
357
|
keepaliveTimer;
|
|
@@ -386,7 +388,7 @@ var SshTransport = class extends EventTarget {
|
|
|
386
388
|
this.sessionId = null;
|
|
387
389
|
this.kex = null;
|
|
388
390
|
this.kexQueue = [];
|
|
389
|
-
this.keepaliveTimer =
|
|
391
|
+
this.keepaliveTimer = void 0;
|
|
390
392
|
this.bytesSent = 0;
|
|
391
393
|
this.bytesReceived = 0;
|
|
392
394
|
}
|
|
@@ -411,10 +413,12 @@ var SshTransport = class extends EventTarget {
|
|
|
411
413
|
}, KEEPALIVE_INTERVAL_MS);
|
|
412
414
|
});
|
|
413
415
|
socket.addEventListener("message", (event) => {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
+
const { data } = event;
|
|
417
|
+
if (typeof data === "string") return;
|
|
418
|
+
this.#receive(new Uint8Array(data));
|
|
416
419
|
});
|
|
417
|
-
socket.addEventListener("close", (
|
|
420
|
+
socket.addEventListener("close", (rawEvent) => {
|
|
421
|
+
const event = rawEvent;
|
|
418
422
|
this.#finish({
|
|
419
423
|
clean: event.code === 1e3,
|
|
420
424
|
code: event.code,
|
|
@@ -445,7 +449,7 @@ var SshTransport = class extends EventTarget {
|
|
|
445
449
|
if (this.closed) return;
|
|
446
450
|
this.closed = true;
|
|
447
451
|
clearInterval(this.keepaliveTimer);
|
|
448
|
-
this.keepaliveTimer =
|
|
452
|
+
this.keepaliveTimer = void 0;
|
|
449
453
|
this.dispatchEvent(new CustomEvent("close", { detail }));
|
|
450
454
|
}
|
|
451
455
|
fail(error) {
|
|
@@ -556,16 +560,18 @@ var SshTransport = class extends EventTarget {
|
|
|
556
560
|
return frame;
|
|
557
561
|
}
|
|
558
562
|
async #decrypt(frame) {
|
|
563
|
+
const incoming = this.incoming;
|
|
564
|
+
if (!incoming) throw new Error("SSH: a packet arrived before the cipher was installed.");
|
|
559
565
|
let plain;
|
|
560
566
|
try {
|
|
561
567
|
plain = new Uint8Array(await subtle2.decrypt(
|
|
562
568
|
{
|
|
563
569
|
name: "AES-GCM",
|
|
564
|
-
iv:
|
|
570
|
+
iv: incoming.nextIv(),
|
|
565
571
|
additionalData: frame.subarray(0, 4),
|
|
566
572
|
tagLength: GCM_TAG_BYTES * 8
|
|
567
573
|
},
|
|
568
|
-
|
|
574
|
+
incoming.key,
|
|
569
575
|
frame.subarray(4)
|
|
570
576
|
));
|
|
571
577
|
} catch {
|
|
@@ -717,6 +723,8 @@ var SshTransport = class extends EventTarget {
|
|
|
717
723
|
this.kex.inbox.push(payload);
|
|
718
724
|
}
|
|
719
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.");
|
|
720
728
|
return new Promise((resolve, reject) => {
|
|
721
729
|
const check = (payload) => {
|
|
722
730
|
if (expectedType !== void 0 && payload[0] !== expectedType) {
|
|
@@ -725,13 +733,13 @@ var SshTransport = class extends EventTarget {
|
|
|
725
733
|
}
|
|
726
734
|
resolve(payload);
|
|
727
735
|
};
|
|
728
|
-
const queued =
|
|
736
|
+
const queued = kex.inbox.shift();
|
|
729
737
|
if (queued) {
|
|
730
738
|
check(queued);
|
|
731
739
|
return;
|
|
732
740
|
}
|
|
733
|
-
|
|
734
|
-
|
|
741
|
+
kex.pending = (payload) => {
|
|
742
|
+
kex.pending = null;
|
|
735
743
|
check(payload);
|
|
736
744
|
};
|
|
737
745
|
});
|
|
@@ -811,7 +819,7 @@ var SshTransport = class extends EventTarget {
|
|
|
811
819
|
]);
|
|
812
820
|
this.outgoing = await GcmCipher.create(keyToServer, ivToServer);
|
|
813
821
|
this.incoming = await GcmCipher.create(keyToClient, ivToClient);
|
|
814
|
-
this.kex.running = false;
|
|
822
|
+
if (this.kex) this.kex.running = false;
|
|
815
823
|
this.releaseNewKeys?.();
|
|
816
824
|
this.releaseNewKeys = null;
|
|
817
825
|
const queued = this.kexQueue;
|
|
@@ -990,16 +998,19 @@ var SshSession = class extends EventTarget {
|
|
|
990
998
|
remoteMaxPacket;
|
|
991
999
|
localWindow;
|
|
992
1000
|
pendingWrites;
|
|
1001
|
+
/** True while the interactive shell channel is usable. */
|
|
993
1002
|
shellOpen;
|
|
994
1003
|
commandInFlight;
|
|
995
1004
|
/** Set while a tool command runs, to keep its framing off the screen. */
|
|
996
1005
|
displayFilter;
|
|
997
1006
|
/** True only after the requested interactive shell is ready for commands. */
|
|
998
|
-
|
|
1007
|
+
/** True once the interactive shell is actually usable, not merely authenticated. */
|
|
1008
|
+
readySignaled = false;
|
|
999
1009
|
powerShellAttempted;
|
|
1000
1010
|
powerShellTimer;
|
|
1001
1011
|
/** '', 'posix', 'cmd' or 'powershell'; learned from the prompt. */
|
|
1002
|
-
|
|
1012
|
+
/** 'powershell' when the host answered with one; '' until the shell is known. */
|
|
1013
|
+
shellFamily = "";
|
|
1003
1014
|
promptTail;
|
|
1004
1015
|
exitStatus;
|
|
1005
1016
|
exitSignal;
|
|
@@ -1143,7 +1154,7 @@ var SshSession = class extends EventTarget {
|
|
|
1143
1154
|
idleMs = COMMAND_IDLE_MS,
|
|
1144
1155
|
maxMs = COMMAND_MAX_MS
|
|
1145
1156
|
} = {}) {
|
|
1146
|
-
const text =
|
|
1157
|
+
const text = (command || "").trim();
|
|
1147
1158
|
if (!text) return Promise.reject(new Error("No command was given."));
|
|
1148
1159
|
if (!this.shellOpen || this.closed) {
|
|
1149
1160
|
return Promise.reject(new Error("The SSH shell is not open."));
|
|
@@ -1430,21 +1441,21 @@ var SshSession = class extends EventTarget {
|
|
|
1430
1441
|
this.#status("authenticating", `Authenticating as ${this.username}\u2026`);
|
|
1431
1442
|
this.transport.send(new SshWriter(32).u8(MSG.SERVICE_REQUEST).string("ssh-userauth").take());
|
|
1432
1443
|
await this.#expect(MSG.SERVICE_ACCEPT);
|
|
1433
|
-
if (await this.#tryNone()) return
|
|
1444
|
+
if (await this.#tryNone()) return await this.#openShell();
|
|
1434
1445
|
if (this.authMethods.includes("publickey")) {
|
|
1435
1446
|
for (const identity of await this.#identities()) {
|
|
1436
|
-
if (await this.#tryPublicKey(identity)) return
|
|
1447
|
+
if (await this.#tryPublicKey(identity)) return await this.#openShell();
|
|
1437
1448
|
}
|
|
1438
1449
|
}
|
|
1439
1450
|
if (this.authMethods.includes("password") && this.password) {
|
|
1440
|
-
if (await this.#tryPassword(this.password)) return
|
|
1451
|
+
if (await this.#tryPassword(this.password)) return await this.#openShell();
|
|
1441
1452
|
this.passwordRejected = true;
|
|
1442
1453
|
}
|
|
1443
1454
|
if (this.authMethods.includes("keyboard-interactive")) {
|
|
1444
|
-
if (await this.#tryKeyboardInteractive()) return
|
|
1455
|
+
if (await this.#tryKeyboardInteractive()) return await this.#openShell();
|
|
1445
1456
|
if (this.canAsk && this.authMethods.includes("keyboard-interactive")) {
|
|
1446
1457
|
if (await this.#tryKeyboardInteractive({ usePassword: false })) {
|
|
1447
|
-
return
|
|
1458
|
+
return await this.#openShell();
|
|
1448
1459
|
}
|
|
1449
1460
|
}
|
|
1450
1461
|
}
|
|
@@ -1453,7 +1464,7 @@ var SshSession = class extends EventTarget {
|
|
|
1453
1464
|
prompt: `${this.username}@${this.options.hostLabel || "host"}'s password: `,
|
|
1454
1465
|
echo: false
|
|
1455
1466
|
});
|
|
1456
|
-
if (typed && await this.#tryPassword(typed)) return
|
|
1467
|
+
if (typed && await this.#tryPassword(typed)) return await this.#openShell();
|
|
1457
1468
|
}
|
|
1458
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.");
|
|
1459
1470
|
} catch (error) {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/cli/known-hosts.ts
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
function configDir() {
|
|
6
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
7
|
+
return join(base, "ai-remote");
|
|
8
|
+
}
|
|
9
|
+
var storePath = () => join(configDir(), "known_hosts.json");
|
|
10
|
+
function read() {
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(readFileSync(storePath(), "utf8"));
|
|
13
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
14
|
+
} catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function write(store) {
|
|
19
|
+
mkdirSync(configDir(), { recursive: true, mode: 448 });
|
|
20
|
+
writeFileSync(storePath(), `${JSON.stringify(store, null, 2)}
|
|
21
|
+
`, { mode: 384 });
|
|
22
|
+
chmodSync(storePath(), 384);
|
|
23
|
+
}
|
|
24
|
+
var hostAddress = (host, port) => `${host}:${port}`;
|
|
25
|
+
function checkHostKey(address, info, { insecure = false } = {}) {
|
|
26
|
+
const store = read();
|
|
27
|
+
const known = store[address];
|
|
28
|
+
if (known && known.fingerprint === info.fingerprint) {
|
|
29
|
+
return { accepted: true, status: "known", message: "" };
|
|
30
|
+
}
|
|
31
|
+
if (known && !insecure) {
|
|
32
|
+
return {
|
|
33
|
+
accepted: false,
|
|
34
|
+
status: "changed",
|
|
35
|
+
message: [
|
|
36
|
+
`The host key for ${address} has changed.`,
|
|
37
|
+
` expected: ${known.fingerprint}`,
|
|
38
|
+
` received: ${info.fingerprint} (${info.keyType})`,
|
|
39
|
+
"Someone may be intercepting this connection. If the machine was genuinely",
|
|
40
|
+
`rebuilt, run \`ai-remote forget-host ${address}\` and connect again.`
|
|
41
|
+
].join("\n")
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
store[address] = {
|
|
45
|
+
fingerprint: info.fingerprint,
|
|
46
|
+
keyType: info.keyType,
|
|
47
|
+
firstSeen: (/* @__PURE__ */ new Date()).toISOString()
|
|
48
|
+
};
|
|
49
|
+
write(store);
|
|
50
|
+
return {
|
|
51
|
+
accepted: true,
|
|
52
|
+
status: known ? "known" : "new",
|
|
53
|
+
message: known ? `Host key for ${address} replaced at your request: ${info.fingerprint}` : `Recorded host key for ${address}: ${info.fingerprint} (${info.keyType})`
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function forgetHostKey(address) {
|
|
57
|
+
const store = read();
|
|
58
|
+
if (!(address in store)) return false;
|
|
59
|
+
delete store[address];
|
|
60
|
+
write(store);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
function knownHosts() {
|
|
64
|
+
return Object.entries(read()).map(([address, entry]) => ({ address, ...entry })).toSorted((a, b) => a.address < b.address ? -1 : a.address > b.address ? 1 : 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export {
|
|
68
|
+
hostAddress,
|
|
69
|
+
checkHostKey,
|
|
70
|
+
forgetHostKey,
|
|
71
|
+
knownHosts
|
|
72
|
+
};
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
SshSession,
|
|
3
3
|
TcpTransport
|
|
4
|
-
} from "./cli-chunk-
|
|
4
|
+
} from "./cli-chunk-UD2YES3P.mjs";
|
|
5
5
|
import {
|
|
6
6
|
SshReader,
|
|
7
7
|
SshWriter
|
|
8
8
|
} from "./cli-chunk-XT2FISR5.mjs";
|
|
9
|
+
import {
|
|
10
|
+
checkHostKey,
|
|
11
|
+
hostAddress
|
|
12
|
+
} from "./cli-chunk-WWORJ2NE.mjs";
|
|
9
13
|
|
|
10
14
|
// src/cli/copy.ts
|
|
11
15
|
import { open as openFile, mkdir, readdir, stat as statFile } from "node:fs/promises";
|
|
@@ -438,10 +442,19 @@ var SftpConnection = class {
|
|
|
438
442
|
// and still redirectable on its own.
|
|
439
443
|
log: (step, detail) => process.stderr.write(`[SSH] ${step} ${JSON.stringify(detail)}
|
|
440
444
|
`),
|
|
441
|
-
// The same
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
+
// The same known_hosts file the terminal uses, on the same accept-new
|
|
446
|
+
// terms: a key that changed stops the copy rather than completing it.
|
|
447
|
+
verifyHost: (info) => {
|
|
448
|
+
const verdict = checkHostKey(
|
|
449
|
+
hostAddress(options.host, options.port),
|
|
450
|
+
info,
|
|
451
|
+
{ insecure: options.insecureHostKey }
|
|
452
|
+
);
|
|
453
|
+
if (verdict.message) process.stderr.write(`${verdict.message}
|
|
454
|
+
`);
|
|
455
|
+
if (!verdict.accepted) this.#error = verdict.message;
|
|
456
|
+
return verdict.accepted;
|
|
457
|
+
},
|
|
445
458
|
openTransport: () => new TcpTransport(options.host, options.port)
|
|
446
459
|
});
|
|
447
460
|
this.sftp = new Sftp({ write: (bytes) => this.#session.write(bytes) });
|