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 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
- Act, then look. The session holds the framebuffer, so a screenshot taken after a
57
- click shows what the click did.
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
- npx ai-remote key MetaLeft # open the Start menu
61
- npx ai-remote shot -o after.png # see what happened
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
- `shot` waits for the screen to stop changing before it captures, so there is
65
- normally no need to sleep first. When an action starts something slow (an app
66
- launching, a page loading), put an explicit wait in a `do` script instead of
67
- guessing from outside.
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; shot s.png"
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; shot s.png"
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-X2JMVOWL.mjs";
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
- // Every host this reaches was named by the person running the command,
31
- // on their own network. There is no stored known_hosts to compare with
32
- // yet, so nothing useful can be decided here.
33
- verifyHost: async () => true,
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-WBOYG4KJ.mjs";
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: 6 }))),
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);
@@ -1,5 +1,5 @@
1
1
  // src/cli/generated/version.ts
2
- var VERSION = "0.5.1";
2
+ var VERSION = "0.6.0";
3
3
 
4
4
  // src/cli/viewer-layout.ts
5
5
  var TERMINAL_COLUMNS = 80;
@@ -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: exchangeHash2 }) {
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, exchangeHash2);
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 = null;
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
- if (typeof event.data === "string") return;
415
- this.#receive(new Uint8Array(event.data));
416
+ const { data } = event;
417
+ if (typeof data === "string") return;
418
+ this.#receive(new Uint8Array(data));
416
419
  });
417
- socket.addEventListener("close", (event) => {
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 = null;
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: this.incoming.nextIv(),
570
+ iv: incoming.nextIv(),
565
571
  additionalData: frame.subarray(0, 4),
566
572
  tagLength: GCM_TAG_BYTES * 8
567
573
  },
568
- this.incoming.key,
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 = this.kex.inbox.shift();
736
+ const queued = kex.inbox.shift();
729
737
  if (queued) {
730
738
  check(queued);
731
739
  return;
732
740
  }
733
- this.kex.pending = (payload) => {
734
- this.kex.pending = null;
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
- readySignaled;
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
- shellFamily;
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 = String(command || "").trim();
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 void await this.#openShell();
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 void await this.#openShell();
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 void await this.#openShell();
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 void await this.#openShell();
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 void await this.#openShell();
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 void await this.#openShell();
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-X2JMVOWL.mjs";
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 reasoning as the terminal's: the person running the command
442
- // named the host, on their own network, and there is no known_hosts here
443
- // to compare against yet.
444
- verifyHost: async () => true,
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) });