@patimweb/pi-ssh 1.0.0 → 1.2.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
@@ -31,7 +31,23 @@ ssh_exec:
31
31
  command: systemctl status nginx --no-pager
32
32
  ```
33
33
 
34
- After `ssh_authorize` the password is no longer needed. It stays in the profile as a fallback until you pass `removePassword: true`.
34
+ The password is only used once. On the **first connection** the extension installs a key on the host and removes the password from the config — see below.
35
+
36
+ ## The password is not kept
37
+
38
+ A password in a config file stays there for as long as the profile does. So the first time a password-only profile is actually used — the first `ssh_exec`, `ssh_list`, tunnel, anything that connects — the extension:
39
+
40
+ 1. generates an ed25519 key,
41
+ 2. installs its public key in the remote `~/.ssh/authorized_keys`,
42
+ 3. opens a second connection using **only** the key to prove it works,
43
+ 4. writes the key path into the profile and **deletes the stored password**,
44
+ 5. and then does what you actually asked for.
45
+
46
+ The tool output says so when this happened, including the fingerprint and where the key was written.
47
+
48
+ If the upgrade fails — a host with `PubkeyAuthentication no`, an unwritable home directory — the password is kept and the work continues, with the reason in the output. The upgrade is attempted, not enforced: a server that refuses keys would otherwise become unusable.
49
+
50
+ To keep using a password, set `autoKey: false` in `ssh_setup`. `ssh_authorize` then remains available to do the switch by hand.
35
51
 
36
52
  ## Tools
37
53
 
@@ -47,6 +63,7 @@ After `ssh_authorize` the password is no longer needed. It stays in the profile
47
63
  | `ssh_keygen` | Create an ed25519 key pair in process. |
48
64
  | `ssh_authorize` | Install a key on a host and stop needing the password. |
49
65
  | `ssh_doctor` | Report what this machine can do and what needs fixing. |
66
+ | `ssh_tunnel` | Open, close and list port forwards, and store named ones in a profile. |
50
67
 
51
68
  ### Passwordless login
52
69
 
@@ -87,6 +104,133 @@ ssh_upload: { localPath: ./dist/app.tar.gz, remotePath: /tmp/app.tar.gz }
87
104
 
88
105
  Missing local directories are created on download. Prefer these over `cat` through `ssh_exec`: SFTP handles binary content and does not push the file through the model.
89
106
 
107
+ ### Tunnels
108
+
109
+ A tunnel is the one thing here that keeps running after its tool call returns — that is what a tunnel is for. It stays up until it is stopped, its time limit expires, or the pi session ends, at which point all of them are closed.
110
+
111
+ There are two directions, and the difference is whose machine each side refers to:
112
+
113
+ | Kind | Who listens | Who reaches the destination | OpenSSH equivalent |
114
+ |------|-------------|-----------------------------|--------------------|
115
+ | `local` | this machine, on `bind:listenPort` | the server, to `destHost:destPort` | `ssh -L` |
116
+ | `remote` | the server, on `bind:listenPort` | this machine, to `destHost:destPort` | `ssh -R` |
117
+
118
+ **Local** is the common case: reach a database, admin interface or internal service that only the server can see.
119
+
120
+ ```yaml
121
+ ssh_tunnel:
122
+ action: start
123
+ name: db
124
+ kind: local
125
+ listenPort: 5432 # 0 picks a free port
126
+ destHost: db.internal # resolved from the server
127
+ destPort: 5432
128
+ # durationSeconds: 3600 # close automatically after an hour
129
+ ```
130
+
131
+ Then connect to `127.0.0.1:5432` on this machine as if the database were local.
132
+
133
+ **Remote** goes the other way: make something running here reachable from the server.
134
+
135
+ ```yaml
136
+ ssh_tunnel:
137
+ action: start
138
+ name: preview
139
+ kind: remote
140
+ listenPort: 8080 # the server listens on this
141
+ destHost: 127.0.0.1 # resolved from this machine
142
+ destPort: 3000
143
+ ```
144
+
145
+ #### Named tunnels in the profile
146
+
147
+ Rather than repeating ports, store a tunnel under a name and start it by that name later. Definitions live in the profile in `~/.pi/ssh-config.json`.
148
+
149
+ ```yaml
150
+ ssh_tunnel:
151
+ action: define
152
+ name: db
153
+ kind: local
154
+ listenPort: 5432
155
+ destHost: db.internal
156
+ destPort: 5432
157
+ description: production database, read replica
158
+ ```
159
+
160
+ ```yaml
161
+ ssh_tunnel: { action: start, name: db } # everything else comes from the profile
162
+ ssh_tunnel: { action: stop, name: db }
163
+ ssh_tunnel: { action: forget, name: db } # remove the definition
164
+ ```
165
+
166
+ The stored form is plain JSON, so it can be written by hand too:
167
+
168
+ ```json
169
+ {
170
+ "profiles": {
171
+ "staging": {
172
+ "host": "staging.example.com",
173
+ "port": 22,
174
+ "user": "deploy",
175
+ "privateKeyPath": "/home/pat/.ssh/id_ed25519_pi_staging",
176
+ "tunnels": {
177
+ "db": {
178
+ "kind": "local",
179
+ "listenPort": 5432,
180
+ "bind": "127.0.0.1",
181
+ "destHost": "db.internal",
182
+ "destPort": 5432,
183
+ "description": "production database, read replica"
184
+ },
185
+ "preview": {
186
+ "kind": "remote",
187
+ "listenPort": 8080,
188
+ "destHost": "127.0.0.1",
189
+ "destPort": 3000
190
+ }
191
+ }
192
+ }
193
+ },
194
+ "activeProfile": "staging"
195
+ }
196
+ ```
197
+
198
+ | Field | Meaning |
199
+ |-------|---------|
200
+ | `kind` | `local` or `remote`, per the table above. |
201
+ | `listenPort` | Port the tunnel accepts connections on. `0` picks a free one. |
202
+ | `bind` | Interface that port binds to. Defaults to `127.0.0.1`. |
203
+ | `destHost` / `destPort` | Where traffic is delivered. |
204
+ | `description` | Free text, shown in listings. |
205
+
206
+ #### Seeing and stopping them
207
+
208
+ ```yaml
209
+ ssh_tunnel: { action: list } # running tunnels and stored definitions
210
+ ssh_tunnel: { action: "stop-all" }
211
+ ```
212
+
213
+ `ssh_status` also lists anything currently running, so a forgotten tunnel does not stay invisible.
214
+
215
+ #### Binding to something other than loopback
216
+
217
+ `bind` defaults to `127.0.0.1`, which means only this machine (or only the server, for a remote tunnel) can use the tunnel. Setting it to `0.0.0.0` publishes the forwarded service to the whole network — the tool output says so when you do. For a remote tunnel, binding anything but loopback additionally needs `GatewayPorts` enabled in the server's `sshd_config`; without it sshd silently binds loopback instead.
218
+
219
+ #### How long it stays up
220
+
221
+ A tunnel holds its own SSH connection open and keeps listening after the tool call returns. It ends when:
222
+
223
+ - you stop it (`action: stop` or `stop-all`),
224
+ - its `durationSeconds` expires,
225
+ - the underlying SSH connection drops, or
226
+ - the pi session ends.
227
+
228
+ Each tunnel owns its own SSH connection. Sharing one would be tidier on the wire, but a single dropped connection would take every tunnel down with it.
229
+
230
+ SSH-level keepalives are enabled (every 15s, four unanswered probes before giving up). ssh2 sends none by default, and without them an idle tunnel behind a NAT or a stateful firewall keeps looking alive long after the path has been dropped. With them, a dead connection is noticed within about a minute, the local listener is closed, and the tunnel disappears from `ssh_tunnel list` — rather than accepting connections that silently go nowhere.
231
+
232
+ There is no automatic reconnect: a tunnel that dies stays dead and has to be started again.
233
+
90
234
  ## Commands
91
235
 
92
236
  | Command | Description |
@@ -131,6 +275,14 @@ Profiles live in `~/.pi/ssh-config.json`, written atomically with mode `0600` be
131
275
 
132
276
  Every tool also takes a one-off `profile` parameter, so several hosts can be used in one session without switching.
133
277
 
278
+ ## Platform support
279
+
280
+ Windows, macOS and Linux behave the same. Nothing in this package spawns a process — SSH comes from [ssh2](https://github.com/mscdex/ssh2) in pure JavaScript, keys are generated with Node's crypto, and there are no POSIX-only paths. A test asserts all three, so a change that introduces one fails the suite rather than only failing on someone else's machine.
281
+
282
+ One difference is real and worth knowing: **file permissions are not enforced on Windows.** The config file and private keys are written with mode `0600`, but Windows governs access through ACLs and `chmod` only toggles the read-only bit. `ssh_doctor` reports this as a note on Windows rather than staying quiet about it. The practical answer is the same as everywhere: let the first connection replace the stored password with a key, so the file stops holding a secret at all.
283
+
284
+ The end-to-end tests need an `sshd` to run against and skip themselves where there is none, which includes Windows.
285
+
134
286
  ## Development
135
287
 
136
288
  ```bash
@@ -139,7 +291,7 @@ npm test
139
291
  npm run test:coverage
140
292
  ```
141
293
 
142
- The suite runs end-to-end against a real OpenSSH server: it starts `sshd` on a loopback port with a host key generated by this package, then exercises the handshake, host key verification (including a simulated key change), exit codes, SFTP, and the full key bootstrap. Those tests skip themselves on machines without `sshd` rather than failing.
294
+ The suite runs end-to-end against a real OpenSSH server: it starts `sshd` on a loopback port with a host key generated by this package, then exercises the handshake, host key verification (including a simulated key change), exit codes, SFTP, the full key bootstrap, and both tunnel directions with real traffic flowing through them. Those tests skip themselves on machines without `sshd` rather than failing.
143
295
 
144
296
  ## License
145
297
 
package/index.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * - ssh_keygen: Create an ed25519 key pair in process
16
16
  * - ssh_authorize: Install a key on a host and stop needing the password
17
17
  * - ssh_doctor: Report what the environment can do and what needs fixing
18
+ * - ssh_tunnel: Open, close and list port forwards, and store named ones
18
19
  *
19
20
  * Nothing here shells out: ssh2 is a pure JavaScript SSH implementation and
20
21
  * keys are generated with Node's own crypto, so Windows, macOS and Linux all
@@ -40,6 +41,8 @@ import { SshDownloadTool } from "./src/tools/ssh-download.ts";
40
41
  import { SshKeygenTool } from "./src/tools/ssh-keygen.ts";
41
42
  import { SshAuthorizeTool } from "./src/tools/ssh-authorize.ts";
42
43
  import { SshDoctorTool } from "./src/tools/ssh-doctor.ts";
44
+ import { SshTunnelTool } from "./src/tools/ssh-tunnel.ts";
45
+ import { stopAllTunnels } from "./src/tunnels.ts";
43
46
 
44
47
  export default function (pi: ExtensionAPI) {
45
48
  // Load saved hosts on startup
@@ -56,6 +59,13 @@ export default function (pi: ExtensionAPI) {
56
59
  pi.registerTool(SshKeygenTool);
57
60
  pi.registerTool(SshAuthorizeTool);
58
61
  pi.registerTool(SshDoctorTool);
62
+ pi.registerTool(SshTunnelTool);
63
+
64
+ // A tunnel is the one thing here that outlives its tool call, so it must
65
+ // not outlive the session that opened it.
66
+ pi.on("session_shutdown", async () => {
67
+ await stopAllTunnels();
68
+ });
59
69
 
60
70
  // Run something on the active host without spelling out the tool call
61
71
  pi.registerCommand("ssh", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-ssh",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "SSH client extension for the pi coding agent. Run commands, transfer files over SFTP, and set up key-based login on remote hosts.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -9,7 +9,22 @@ allowed-tools: ssh_setup, ssh_status, ssh_keygen, ssh_authorize, ssh_exec, ssh_d
9
9
  A password in a config file is a password on disk, and typing one into every
10
10
  session is friction. A key fixes both. The whole switch is one tool call.
11
11
 
12
- ## The normal path
12
+ ## It usually happens by itself
13
+
14
+ A profile configured with only a password upgrades itself on first use: the
15
+ first tool that connects installs a key, verifies it, and **removes the
16
+ password from the config**. Nothing needs to be called for this, and the tool
17
+ output reports it. Say so when relaying that output - the user should know
18
+ their password is no longer stored and where the key went.
19
+
20
+ If the upgrade failed, the note explains why and the password is still in
21
+ place. That is worth surfacing rather than glossing over: it means the host
22
+ refused key authentication, and the password is still on disk.
23
+
24
+ `autoKey: false` on `ssh_setup` turns the automatic switch off for hosts where
25
+ it is not wanted.
26
+
27
+ ## Doing it by hand
13
28
 
14
29
  1. `ssh_setup` with host, user and the password.
15
30
  2. `ssh_authorize` on that profile.
@@ -34,6 +49,11 @@ run `ssh_doctor` and show them the report rather than guessing.
34
49
  The keys produced are ordinary OpenSSH ed25519 keys, so `ssh -i` and any other
35
50
  SSH client can use the same file.
36
51
 
52
+ On Windows there is one caveat worth passing on: the owner-only file modes
53
+ this extension sets are not enforced there, because access is governed by
54
+ ACLs. That makes replacing a stored password with a key more valuable, not
55
+ less. `ssh_doctor` says so on that platform.
56
+
37
57
  ## Things worth getting right
38
58
 
39
59
  - **Never overwrite an existing key.** Every host that already trusts it would
@@ -74,6 +74,21 @@ The error usually says which layer failed. Work from it rather than retrying:
74
74
  - Something about the local environment - run `ssh_doctor`, which reports what
75
75
  is missing and what to do about it.
76
76
 
77
+ ## Tunnels
78
+
79
+ `ssh_tunnel` is the one tool here whose effect outlives the call: a forward
80
+ keeps running until stopped, its time limit expires, or the session ends.
81
+
82
+ - Start one only when something actually needs it, and tell the user it is
83
+ running and how to reach it.
84
+ - `ssh_tunnel action list`, and `ssh_status`, show what is open. Check there
85
+ before starting another one with the same purpose.
86
+ - Stop tunnels when the work that needed them is done rather than leaving them
87
+ open for the rest of the session.
88
+ - `bind` defaults to loopback. Do not set it to `0.0.0.0` unless the user asked
89
+ for the service to be reachable from other machines, and say plainly what
90
+ that exposes when you do.
91
+
77
92
  ## What to report back
78
93
 
79
94
  Give the user the command's actual output and its exit code. When a command
@@ -0,0 +1,83 @@
1
+ /**
2
+ * First contact upgrades a password login to a key login.
3
+ *
4
+ * A password sitting in a config file is a password on disk, and it stays
5
+ * there for as long as the profile exists. So the first time a profile that
6
+ * has only a password is actually used, the key bootstrap runs first and the
7
+ * password is replaced by the key it just installed.
8
+ *
9
+ * The upgrade is attempted, not enforced: a host that refuses public key
10
+ * authentication entirely would otherwise become unusable. When it fails the
11
+ * password is kept and the caller is told, rather than the work being blocked.
12
+ */
13
+
14
+ import type { SshProfile } from "./types.ts";
15
+ import { authorizeKey } from "./authorize.ts";
16
+ import { getProfile } from "./config.ts";
17
+
18
+ export interface UpgradeOutcome {
19
+ /** The profile to actually connect with. */
20
+ readonly profile: SshProfile;
21
+ /** Set when an upgrade was attempted, whether or not it worked. */
22
+ readonly note?: string;
23
+ readonly upgraded: boolean;
24
+ }
25
+
26
+ /** Does this profile still log in with a password and nothing else? */
27
+ export function needsKeyUpgrade(profile: SshProfile): boolean {
28
+ if (profile.autoKey === false) return false;
29
+ return Boolean(profile.password) && !profile.privateKeyPath;
30
+ }
31
+
32
+ export interface UpgradeOptions {
33
+ readonly acceptNewHostKey?: boolean;
34
+ readonly signal?: AbortSignal;
35
+ }
36
+
37
+ /**
38
+ * Install a key and drop the password, then return the profile to connect
39
+ * with. A profile that already has a key, or has opted out, is returned
40
+ * untouched without opening any connection.
41
+ */
42
+ export async function ensureKeyAuthentication(
43
+ profileName: string,
44
+ profile: SshProfile,
45
+ options: UpgradeOptions = {},
46
+ ): Promise<UpgradeOutcome> {
47
+ if (!needsKeyUpgrade(profile)) {
48
+ return { profile, upgraded: false };
49
+ }
50
+
51
+ try {
52
+ const result = await authorizeKey({
53
+ profileName,
54
+ profile,
55
+ acceptNewHostKey: options.acceptNewHostKey,
56
+ signal: options.signal,
57
+ // The point of the upgrade is that the password stops being stored.
58
+ removePassword: true,
59
+ });
60
+
61
+ if (!result.verified) {
62
+ return {
63
+ profile,
64
+ upgraded: false,
65
+ note: `A key was installed on ${profile.host}, but a key-only login could not be verified, so the password is still being used. Fingerprint: ${result.fingerprint}`,
66
+ };
67
+ }
68
+
69
+ // authorizeKey rewrote the stored profile; use what is on disk now.
70
+ const updated = getProfile(profileName) ?? profile;
71
+ return {
72
+ profile: updated,
73
+ upgraded: true,
74
+ note: `First connection to ${profile.host}: installed a key (${result.fingerprint}) at ${result.keyPath} and removed the stored password. Logins from now on use the key.`,
75
+ };
76
+ } catch (err) {
77
+ return {
78
+ profile,
79
+ upgraded: false,
80
+ note: `Could not switch ${profile.host} to key authentication (${(err as Error).message}). Continuing with the password.`,
81
+ };
82
+ }
83
+ }
@@ -40,6 +40,17 @@ import {
40
40
  import { expandPath } from "../config.ts";
41
41
 
42
42
  const DEFAULT_CONNECT_TIMEOUT_MS = 20_000;
43
+ /**
44
+ * SSH-level keepalives.
45
+ *
46
+ * ssh2 sends none by default. A connection that carries a tunnel can sit idle
47
+ * for a long time, and anything doing NAT or stateful filtering in between
48
+ * will eventually drop an idle flow without telling either end -- the tunnel
49
+ * then looks alive and silently is not. Four unanswered probes at 15s means a
50
+ * dead connection is noticed within about a minute and closed properly.
51
+ */
52
+ const KEEPALIVE_INTERVAL_MS = 15_000;
53
+ const KEEPALIVE_COUNT_MAX = 4;
43
54
  const DEFAULT_EXEC_TIMEOUT_MS = 120_000;
44
55
  /** Enough to be useful, small enough not to swamp a context window. */
45
56
  const MAX_OUTPUT_CHARS = 200_000;
@@ -71,7 +82,7 @@ function readPrivateKey(profile: SshProfile): Buffer | null {
71
82
  }
72
83
 
73
84
  /** The authentication methods to offer, in the order they should be tried. */
74
- function buildAuthMethods(
85
+ export function buildAuthMethods(
75
86
  profile: SshProfile,
76
87
  only: ConnectOptions["only"],
77
88
  ): Array<{ type: string; label: string; key?: Buffer; passphrase?: string; password?: string }> {
@@ -143,6 +154,8 @@ export function connect(
143
154
  port: profile.port,
144
155
  username: profile.user,
145
156
  readyTimeout: profile.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
157
+ keepaliveInterval: KEEPALIVE_INTERVAL_MS,
158
+ keepaliveCountMax: KEEPALIVE_COUNT_MAX,
146
159
 
147
160
  hostVerifier: (key: Buffer, verify: (ok: boolean) => void) => {
148
161
  const check = checkHostKey(profile.host, profile.port, key, knownHostsFile);
package/src/config.ts CHANGED
@@ -18,7 +18,7 @@ let profiles: Record<string, SshProfile> = {};
18
18
  let activeProfile: string | null = null;
19
19
 
20
20
  function configPath(): string {
21
- const home = process.env.HOME || process.env.USERPROFILE || "~";
21
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
22
22
  return path.join(home, ".pi", "ssh-config.json");
23
23
  }
24
24
 
package/src/doctor.ts CHANGED
@@ -25,6 +25,33 @@ export interface Check {
25
25
  readonly remedy?: string;
26
26
  }
27
27
 
28
+ /**
29
+ * Whether file permissions mean anything on this platform.
30
+ *
31
+ * The config file can hold a password and the private keys are secrets, so
32
+ * they are written 0600. On Windows chmod only toggles the read-only bit and
33
+ * access is governed by ACLs instead, so those modes are not enforced. Saying
34
+ * nothing would be the wrong kind of quiet: the situation there is weaker,
35
+ * not stronger.
36
+ */
37
+ export function describePermissionSupport(platform: string): Check {
38
+ if (platform !== "win32") {
39
+ return {
40
+ name: "File permissions",
41
+ status: "ok",
42
+ detail: "secrets are written owner-only (0600) and that is enforced",
43
+ };
44
+ }
45
+ return {
46
+ name: "File permissions",
47
+ status: "note",
48
+ detail:
49
+ "Windows ignores the owner-only modes this extension sets; access is governed by ACLs instead",
50
+ remedy:
51
+ "~/.pi/ssh-config.json can hold a password and ~/.ssh holds private keys. Make sure your user profile directory is not shared, or let ssh_authorize replace the password with a key.",
52
+ };
53
+ }
54
+
28
55
  function checkNodeVersion(): Check {
29
56
  const major = Number.parseInt(process.versions.node.split(".")[0], 10);
30
57
  return major >= 20
@@ -189,6 +216,7 @@ export function runChecks(): Check[] {
189
216
  checkSsh2(),
190
217
  checkKeyGeneration(),
191
218
  checkOpenSshClient(),
219
+ describePermissionSupport(process.platform),
192
220
  checkSshDirectory(),
193
221
  checkKnownHosts(),
194
222
  checkConfigDirectory(),
@@ -7,6 +7,8 @@
7
7
 
8
8
  import type {
9
9
  AuthorizeResult,
10
+ RunningTunnel,
11
+ TunnelDefinition,
10
12
  ExecResult,
11
13
  RemoteEntry,
12
14
  ServerIdentity,
@@ -131,3 +133,77 @@ export function formatAuthorizeResult(result: AuthorizeResult): string {
131
133
  ];
132
134
  return lines.join("\n");
133
135
  }
136
+
137
+ /** One line describing where a forward starts and where it ends. */
138
+ export function describeTunnel(definition: TunnelDefinition): string {
139
+ const bind = definition.bind ?? "127.0.0.1";
140
+ const listen = `${bind}:${definition.listenPort === 0 ? "(free port)" : definition.listenPort}`;
141
+ const dest = `${definition.destHost}:${definition.destPort}`;
142
+
143
+ return definition.kind === "local"
144
+ ? `local ${listen} -> ${dest} (reached from the server)`
145
+ : `remote ${listen} on the server -> ${dest} (reached from this machine)`;
146
+ }
147
+
148
+ export function formatTunnelStarted(
149
+ tunnel: RunningTunnel,
150
+ profile: SshProfile,
151
+ ): string {
152
+ const bind = tunnel.definition.bind ?? "127.0.0.1";
153
+ const lines = [
154
+ `Tunnel "${tunnel.name}" is up: ${describeTunnel(tunnel.definition)}`,
155
+ tunnel.definition.kind === "local"
156
+ ? `Connect to ${tunnel.listenAddress} on this machine.`
157
+ : `On ${profile.host}, connect to ${tunnel.listenAddress}.`,
158
+ "",
159
+ "It keeps running in the background until you stop it with ssh_tunnel action stop"
160
+ + (tunnel.expiresAt ? `, or automatically at ${tunnel.expiresAt.slice(11, 19)} UTC.` : ", or the pi session ends."),
161
+ ];
162
+
163
+ if (bind !== "127.0.0.1" && bind !== "localhost") {
164
+ lines.push(
165
+ "",
166
+ `Note: this binds ${bind}, not loopback, so anything that can reach that interface can use the tunnel.`,
167
+ );
168
+ }
169
+ return lines.join("\n");
170
+ }
171
+
172
+ export function formatTunnelList(
173
+ running: ReadonlyArray<RunningTunnel>,
174
+ defined: ReadonlyArray<{ profile: string; name: string; definition: TunnelDefinition }>,
175
+ ): string {
176
+ const sections: string[] = [];
177
+
178
+ if (running.length === 0) {
179
+ sections.push("No tunnels are running.");
180
+ } else {
181
+ sections.push(`Running tunnels (${running.length}):`);
182
+ for (const tunnel of running) {
183
+ sections.push(
184
+ `- ${tunnel.profile}/${tunnel.name}: ${describeTunnel(tunnel.definition)}`,
185
+ ` listening on ${tunnel.listenAddress}, ${tunnel.connections} connection(s) since ${tunnel.startedAt.slice(11, 19)} UTC`
186
+ + (tunnel.expiresAt ? `, closes at ${tunnel.expiresAt.slice(11, 19)} UTC` : ""),
187
+ );
188
+ }
189
+ }
190
+
191
+ if (defined.length > 0) {
192
+ const runningIds = new Set(running.map((tunnel) => `${tunnel.profile}:${tunnel.name}`));
193
+ sections.push("", `Defined in profiles (${defined.length}):`);
194
+ for (const entry of defined) {
195
+ const state = runningIds.has(`${entry.profile}:${entry.name}`) ? " [running]" : "";
196
+ const purpose = entry.definition.description ? ` -- ${entry.definition.description}` : "";
197
+ sections.push(
198
+ `- ${entry.profile}/${entry.name}${state}: ${describeTunnel(entry.definition)}${purpose}`,
199
+ );
200
+ }
201
+ } else if (running.length === 0) {
202
+ sections.push(
203
+ "",
204
+ "Define one with ssh_tunnel action define, so it can be started by name later.",
205
+ );
206
+ }
207
+
208
+ return sections.join("\n");
209
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * What every tool that opens a connection does first.
3
+ *
4
+ * Resolving the profile and upgrading it to key authentication belong
5
+ * together: the upgrade has to happen before the connection the tool actually
6
+ * wants, and its outcome has to reach the user, so both are done in one place
7
+ * rather than repeated in each tool.
8
+ */
9
+
10
+ import { resolveProfile } from "../config.ts";
11
+ import { ensureKeyAuthentication } from "../auto-key.ts";
12
+ import type { SshProfile } from "../types.ts";
13
+
14
+ export interface ConnectionContext {
15
+ readonly name: string;
16
+ readonly profile: SshProfile;
17
+ /** Worth showing the user, e.g. that the password was just replaced. */
18
+ readonly note?: string;
19
+ }
20
+
21
+ export async function resolveForConnection(
22
+ profileName: string | undefined,
23
+ options: { acceptNewHostKey?: boolean; signal?: AbortSignal } = {},
24
+ ): Promise<ConnectionContext> {
25
+ const { name, profile } = resolveProfile(profileName);
26
+ const outcome = await ensureKeyAuthentication(name, profile, options);
27
+ return { name, profile: outcome.profile, note: outcome.note };
28
+ }
29
+
30
+ /** Append a note to a tool's text output, when there is one. */
31
+ export function withNote(text: string, note?: string): string {
32
+ return note ? `${text}\n\n${note}` : text;
33
+ }
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { Type } from "typebox";
6
- import { resolveProfile } from "../config.ts";
6
+ import { resolveForConnection, withNote } from "./shared.ts";
7
7
  import { downloadFile, withConnection } from "../clients/ssh-client.ts";
8
8
  import { formatTransfer } from "../formatting/formatters.ts";
9
9
 
@@ -35,7 +35,10 @@ export const SshDownloadTool = {
35
35
  },
36
36
  signal: AbortSignal,
37
37
  ) {
38
- const { name, profile } = resolveProfile(params.profile);
38
+ const { name, profile, note } = await resolveForConnection(params.profile, {
39
+ acceptNewHostKey: params.acceptNewHostKey,
40
+ signal,
41
+ });
39
42
 
40
43
  const result = await withConnection(
41
44
  profile,
@@ -44,7 +47,7 @@ export const SshDownloadTool = {
44
47
  );
45
48
 
46
49
  return {
47
- content: [{ type: "text" as const, text: formatTransfer(result, "down") }],
50
+ content: [{ type: "text" as const, text: withNote(formatTransfer(result, "down"), note) }],
48
51
  details: { profile: name, ...result },
49
52
  };
50
53
  },
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { Type } from "typebox";
6
- import { resolveProfile } from "../config.ts";
6
+ import { resolveForConnection, withNote } from "./shared.ts";
7
7
  import { execCommand, withConnection } from "../clients/ssh-client.ts";
8
8
  import { formatExecResult } from "../formatting/formatters.ts";
9
9
 
@@ -45,7 +45,10 @@ export const SshExecTool = {
45
45
  ) {
46
46
  if (!params.command?.trim()) throw new Error("command must not be empty.");
47
47
 
48
- const { name, profile } = resolveProfile(params.profile);
48
+ const { name, profile, note } = await resolveForConnection(params.profile, {
49
+ acceptNewHostKey: params.acceptNewHostKey,
50
+ signal,
51
+ });
49
52
  const timeoutMs = Math.min(Math.max(params.timeoutSeconds ?? 120, 1), 3600) * 1000;
50
53
 
51
54
  const result = await withConnection(
@@ -60,7 +63,7 @@ export const SshExecTool = {
60
63
  );
61
64
 
62
65
  return {
63
- content: [{ type: "text" as const, text: formatExecResult(result) }],
66
+ content: [{ type: "text" as const, text: withNote(formatExecResult(result), note) }],
64
67
  details: {
65
68
  profile: name,
66
69
  host: profile.host,
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { Type } from "typebox";
6
- import { resolveProfile } from "../config.ts";
6
+ import { resolveForConnection, withNote } from "./shared.ts";
7
7
  import { listDirectory, withConnection } from "../clients/ssh-client.ts";
8
8
  import { formatDirectory } from "../formatting/formatters.ts";
9
9
 
@@ -27,7 +27,10 @@ export const SshListTool = {
27
27
  params: { path: string; profile?: string; acceptNewHostKey?: boolean },
28
28
  signal: AbortSignal,
29
29
  ) {
30
- const { name, profile } = resolveProfile(params.profile);
30
+ const { name, profile, note } = await resolveForConnection(params.profile, {
31
+ acceptNewHostKey: params.acceptNewHostKey,
32
+ signal,
33
+ });
31
34
  const remotePath = params.path?.trim() || ".";
32
35
 
33
36
  const entries = await withConnection(
@@ -37,7 +40,9 @@ export const SshListTool = {
37
40
  );
38
41
 
39
42
  return {
40
- content: [{ type: "text" as const, text: formatDirectory(entries, remotePath) }],
43
+ content: [
44
+ { type: "text" as const, text: withNote(formatDirectory(entries, remotePath), note) },
45
+ ],
41
46
  details: {
42
47
  profile: name,
43
48
  path: remotePath,