pi-ssh-remote 0.1.7 → 0.1.8
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/CHANGELOG.md +18 -1
- package/LICENSE +0 -0
- package/README.md +22 -12
- package/README.zh-CN.md +23 -12
- package/index.ts +221 -62
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,22 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
|
|
|
18
18
|
|
|
19
19
|
- None.
|
|
20
20
|
|
|
21
|
+
## [0.1.8] - 2026-08-10
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Added explicit private-key authentication through `ssh -i KEY`, including passphrase-protected keys and in-memory passphrase caching for reconnects.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Renamed the agent-facing `ssh_remote_control` tool to the shorter `remote` name, matching the `/remote` command.
|
|
30
|
+
- Server memory is now keyed by `user@host` and shared across SSH ports; existing endpoint memories are migrated automatically.
|
|
31
|
+
- `/remote forget` now clears cached private-key passphrases as well as passwords.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- None.
|
|
36
|
+
|
|
21
37
|
## [0.1.7] - 2026-08-05
|
|
22
38
|
|
|
23
39
|
### Added
|
|
@@ -145,7 +161,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
|
|
|
145
161
|
|
|
146
162
|
- None.
|
|
147
163
|
|
|
148
|
-
[Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.
|
|
164
|
+
[Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.8...HEAD
|
|
165
|
+
[0.1.8]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.7...v0.1.8
|
|
149
166
|
[0.1.7]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.6...v0.1.7
|
|
150
167
|
[0.1.6]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.5...v0.1.6
|
|
151
168
|
[0.1.5]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.4...v0.1.5
|
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
@@ -27,11 +27,11 @@ Once connected, Pi's file tools, shell tool, and `!` user shell commands are tra
|
|
|
27
27
|
|
|
28
28
|
### Agent control plane, human control plane
|
|
29
29
|
|
|
30
|
-
The extension exposes `
|
|
30
|
+
The extension exposes `remote` for the agent and `/remote` for the user. You can ask Pi to connect, inspect status, change directory, add a server note, execute a command, create a tunnel, or return to local work in natural language—and still take direct control whenever you want.
|
|
31
31
|
|
|
32
32
|
### Stateful endpoints instead of disposable SSH commands
|
|
33
33
|
|
|
34
|
-
Each `user@host:port` endpoint remembers its remote working directory, note,
|
|
34
|
+
Each `user@host:port` endpoint remembers its remote working directory, note, and port-forward configuration locally. Server memory is shared by `user@host`, so connecting as the same user to the same host through another port uses the same memory. Notes such as `H100 training`, `staging`, or `customer demo` appear in configuration, status messages, and Pi's footer, so the active execution environment stays visible. Server memory is automatically injected into the model context whenever a matching endpoint is connected as the active remote workspace.
|
|
35
35
|
|
|
36
36
|
### Resilient and bounded by default
|
|
37
37
|
|
|
@@ -82,6 +82,16 @@ Return to local tools with:
|
|
|
82
82
|
/remote off
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
### Private key authentication
|
|
86
|
+
|
|
87
|
+
Pass an explicit local private key with `-i`:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
/remote ssh -i ~/.ssh/id_ed25519 root@gpu-box.example.com -p 2202
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Identity paths must be absolute or start with `~/`. Unencrypted and passphrase-protected private keys are supported. Pi prompts for an encrypted key's passphrase and caches it only in the current process for reconnects; `/remote forget` clears it. When `-i` is present, that key is used exclusively instead of silently falling back to SSH agent or password authentication. Commands without `-i` keep the existing SSH agent and password flow unchanged.
|
|
94
|
+
|
|
85
95
|
## Examples
|
|
86
96
|
|
|
87
97
|
### 1. Let the agent investigate and repair a remote failure
|
|
@@ -121,7 +131,7 @@ The default foreground timeout prevents an accidental long-running command from
|
|
|
121
131
|
/remote
|
|
122
132
|
```
|
|
123
133
|
|
|
124
|
-
Endpoint notes
|
|
134
|
+
Endpoint notes and working directories, plus `user@host`-specific server memories, survive Pi restarts because they are stored in the local remote configuration. When Pi connects to an endpoint, the memory matching its user and host is added to the system context on every agent run while remote tool routing remains active; the SSH port does not affect memory selection. Disconnecting or switching to tunnel-only mode removes it from subsequent model requests.
|
|
125
135
|
|
|
126
136
|
### 4. Expose a remote service while editing locally
|
|
127
137
|
|
|
@@ -154,20 +164,20 @@ Forward the remote service on port 8000 to localhost:8000, but keep my coding
|
|
|
154
164
|
tools on the local repository.
|
|
155
165
|
```
|
|
156
166
|
|
|
157
|
-
These requests are handled through the agent-facing `
|
|
167
|
+
These requests are handled through the agent-facing `remote` tool; slash commands are optional.
|
|
158
168
|
|
|
159
169
|
## Command reference
|
|
160
170
|
|
|
161
171
|
| Command | Purpose |
|
|
162
172
|
|---|---|
|
|
163
|
-
| `/remote ssh USER@HOST -p PORT` | Save, select, and connect
|
|
173
|
+
| `/remote ssh USER@HOST -p PORT [-i KEY]` | Save, select, and connect using agent/password or an explicit private key |
|
|
164
174
|
| `/remote` | Connect to the selected endpoint or prompt for one |
|
|
165
175
|
| `/remote config` | List saved endpoints and settings |
|
|
166
176
|
| `/remote use USER@HOST:PORT` | Select a saved endpoint |
|
|
167
177
|
| `/remote config note TEXT` | Persist a note for the selected endpoint |
|
|
168
178
|
| `/remote config note --clear` | Clear its note |
|
|
169
|
-
| `/remote config memory TEXT` | Persist context
|
|
170
|
-
| `/remote config memory --clear` | Clear
|
|
179
|
+
| `/remote config memory TEXT` | Persist context for the current `user@host`, shared across ports |
|
|
180
|
+
| `/remote config memory --clear` | Clear the current `user@host` memory |
|
|
171
181
|
| `/remote config cwd PATH` | Persist its default remote working directory |
|
|
172
182
|
| `/remote cd PATH` | Change the connected remote cwd and persist it |
|
|
173
183
|
| `/remote config forward MAPPING...` | Persist port forwards such as `7860:127.0.0.1:7860` |
|
|
@@ -185,7 +195,7 @@ These requests are handled through the agent-facing `ssh_remote_control` tool; s
|
|
|
185
195
|
| `/remote status` | Show the active workspace |
|
|
186
196
|
| `/remote reload` | Reconnect the active workspace |
|
|
187
197
|
| `/remote off` | Disconnect and return tools to local execution |
|
|
188
|
-
| `/remote forget` | Disconnect and clear
|
|
198
|
+
| `/remote forget` | Disconnect and clear cached passwords and key passphrases |
|
|
189
199
|
|
|
190
200
|
## Persistence, output, and security
|
|
191
201
|
|
|
@@ -195,23 +205,23 @@ Endpoint configuration is stored locally in:
|
|
|
195
205
|
~/.pi/agent/ssh-remote-config.json
|
|
196
206
|
```
|
|
197
207
|
|
|
198
|
-
Saved global values include endpoints, active endpoint, notes,
|
|
208
|
+
Saved global values include endpoints, active endpoint, notes, `user@host`-specific server memories, remote working directories, forwards, identity file paths, preview settings, and model-output budgets. Each Pi session also stores non-secret SSH workspace metadata so `/resume` can restore the server associated with that session. Server memory is user-configured trusted context and is inserted into each model request only while a matching endpoint is the active remote workspace; it is not read from the remote server. Private key contents, passwords, and key passphrases are **never written to this file**. Private keys are read locally only when connecting; prompted passwords and passphrases remain only in process memory.
|
|
199
209
|
|
|
200
210
|
New or changed host keys require interactive confirmation and are stored separately from OpenSSH. By default, remote text reads return at most 400 lines or 16 KB, remote command results return the last 200 lines or 8 KB, and all remote tools in one agent turn share a 32 KB output budget. Text reads support `offset`/`limit` continuation without downloading the complete remote file. Oversized command output is streamed to a permission-restricted temporary local file rather than accumulated in memory. Configured limits may be raised only to the extension's hard safety ceilings. Preview-line settings affect only the collapsed UI and never increase model output.
|
|
201
211
|
|
|
202
212
|
## Current SSH scope
|
|
203
213
|
|
|
204
|
-
The extension currently supports direct SSH commands with `-p
|
|
214
|
+
The extension currently supports direct SSH commands with `-p`, `-l`, and `-i`. It does not yet consume `~/.ssh/config` or ProxyJump settings; use `-i` explicitly instead of relying on an `IdentityFile` entry.
|
|
205
215
|
|
|
206
216
|
## Releases
|
|
207
217
|
|
|
208
|
-
Latest release: [v0.1.
|
|
218
|
+
Latest release: [v0.1.8](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.8)
|
|
209
219
|
|
|
210
220
|
| Version | Date | Highlights |
|
|
211
221
|
|---|---|---|
|
|
222
|
+
| [0.1.8](CHANGELOG.md#018---2026-08-10) | 2026-08-10 | Explicit private-key login, cross-port server memory, and the shorter `remote` tool name |
|
|
212
223
|
| [0.1.7](CHANGELOG.md#017---2026-08-05) | 2026-08-05 | Session-aware SSH workspace restoration for new, forked, and resumed sessions |
|
|
213
224
|
| [0.1.6](CHANGELOG.md#016---2026-08-05) | 2026-08-05 | Bounded remote reads, streamed command output, and per-turn output budgets |
|
|
214
|
-
| [0.1.5](CHANGELOG.md#015---2026-08-04) | 2026-08-04 | Endpoint-specific server memory and automatic context injection |
|
|
215
225
|
|
|
216
226
|
See [CHANGELOG.md](CHANGELOG.md) for the complete release history, including additions, behavior changes, and bug fixes.
|
|
217
227
|
|
package/README.zh-CN.md
CHANGED
|
@@ -54,7 +54,7 @@ SSH remote H100 训练机 (root@gpu-box.example.com:2202):/srv/project
|
|
|
54
54
|
- 服务器特定记忆;
|
|
55
55
|
- 端口转发配置。
|
|
56
56
|
|
|
57
|
-
例如可以把几台机器分别备注为 `8xH100
|
|
57
|
+
例如可以把几台机器分别备注为 `8xH100 训练机`、`预发布环境`、`线上只读机`。服务器记忆按 `user@host` 保存,例如指定 Python 环境、共享任务保护规则或部署约定;同一用户和主机即使通过不同 SSH 端口连接,也会使用同一份记忆。连接匹配的 endpoint 并启用远端工具路由后,这段记忆会自动注入模型上下文;断开连接或切换到纯隧道模式后,后续请求不再注入。所有配置保存在本地,重启 Pi 后仍然存在。
|
|
58
58
|
|
|
59
59
|
### 断线后可以自动恢复
|
|
60
60
|
|
|
@@ -124,6 +124,16 @@ pi install npm:pi-ssh-remote
|
|
|
124
124
|
/remote off
|
|
125
125
|
```
|
|
126
126
|
|
|
127
|
+
### 使用私钥文件登录
|
|
128
|
+
|
|
129
|
+
通过 `-i` 指定本地私钥:
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
/remote ssh -i ~/.ssh/id_ed25519 root@gpu-box.example.com -p 2202
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
密钥路径必须是绝对路径或以 `~/` 开头。插件支持未加密私钥和带 passphrase 的私钥;对于加密私钥,Pi 会以遮罩方式询问 passphrase,并仅在当前进程内缓存以便自动重连,`/remote forget` 会清除它。指定 `-i` 后只使用该私钥,不会静默回退到 SSH Agent 或密码;不带 `-i` 的旧命令仍保持原有的 SSH Agent 和密码登录流程。
|
|
136
|
+
|
|
127
137
|
## 常见用法
|
|
128
138
|
|
|
129
139
|
### 1. 让 Pi 直接排查远程服务故障
|
|
@@ -173,7 +183,7 @@ pi install npm:pi-ssh-remote
|
|
|
173
183
|
/remote
|
|
174
184
|
```
|
|
175
185
|
|
|
176
|
-
备注和默认目录会按 `user@host:port`
|
|
186
|
+
备注和默认目录会按 `user@host:port` 分别保存,不会互相覆盖;服务器记忆则按 `user@host` 共享,不受端口影响。
|
|
177
187
|
|
|
178
188
|
### 4. 远端启动模型,本地开发界面
|
|
179
189
|
|
|
@@ -208,20 +218,20 @@ pi install npm:pi-ssh-remote
|
|
|
208
218
|
把远端 8000 端口转发到本地 8000,但代码工具继续留在本地。
|
|
209
219
|
```
|
|
210
220
|
|
|
211
|
-
Pi 会通过插件提供的 `
|
|
221
|
+
Pi 会通过插件提供的 `remote` 工具完成这些操作。
|
|
212
222
|
|
|
213
223
|
## 命令说明
|
|
214
224
|
|
|
215
225
|
| 命令 | 作用 |
|
|
216
226
|
|---|---|
|
|
217
|
-
| `/remote ssh USER@HOST -p PORT` |
|
|
227
|
+
| `/remote ssh USER@HOST -p PORT [-i KEY]` | 保存并使用 Agent/密码或指定私钥连接服务器 |
|
|
218
228
|
| `/remote` | 连接当前选中的服务器,或提示输入 SSH 地址 |
|
|
219
229
|
| `/remote config` | 查看已保存的服务器和配置 |
|
|
220
230
|
| `/remote use USER@HOST:PORT` | 切换到指定服务器 |
|
|
221
231
|
| `/remote config note TEXT` | 给当前服务器添加或修改备注 |
|
|
222
232
|
| `/remote config note --clear` | 清除当前服务器备注 |
|
|
223
|
-
| `/remote config memory TEXT` |
|
|
224
|
-
| `/remote config memory --clear` |
|
|
233
|
+
| `/remote config memory TEXT` | 保存当前 `user@host` 的记忆,并在不同端口间共享 |
|
|
234
|
+
| `/remote config memory --clear` | 清除当前 `user@host` 的记忆 |
|
|
225
235
|
| `/remote config cwd PATH` | 设置默认远程工作目录 |
|
|
226
236
|
| `/remote cd PATH` | 切换当前远程目录并保存 |
|
|
227
237
|
| `/remote config forward MAPPING...` | 保存端口转发配置,例如 `7860:127.0.0.1:7860` |
|
|
@@ -239,7 +249,7 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
|
|
|
239
249
|
| `/remote status` | 查看当前连接和工作目录 |
|
|
240
250
|
| `/remote reload` | 重新连接当前服务器 |
|
|
241
251
|
| `/remote off` | 断开连接并返回本地 |
|
|
242
|
-
| `/remote forget` |
|
|
252
|
+
| `/remote forget` | 断开连接并清除内存中的密码和密钥 passphrase |
|
|
243
253
|
|
|
244
254
|
## 配置保存在哪里
|
|
245
255
|
|
|
@@ -257,12 +267,13 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
|
|
|
257
267
|
- 服务器特定记忆;
|
|
258
268
|
- 默认远程目录;
|
|
259
269
|
- 端口转发配置;
|
|
270
|
+
- 私钥文件路径;
|
|
260
271
|
- 命令预览设置;
|
|
261
272
|
- 模型输出预算。
|
|
262
273
|
|
|
263
|
-
此外,每个 Pi session 都会记录不含凭据的 SSH 工作区元数据,用于在 `/resume` 时恢复该历史 session
|
|
274
|
+
此外,每个 Pi session 都会记录不含凭据的 SSH 工作区元数据,用于在 `/resume` 时恢复该历史 session 对应的服务器环境。服务器记忆按 `user@host` 识别,不受 SSH 端口影响。它是由用户在本地配置的可信上下文,不会从远程服务器自动读取;仅当匹配的 endpoint 作为远端工作区启用时才会加入每次模型请求。
|
|
264
275
|
|
|
265
|
-
|
|
276
|
+
私钥内容、密码和密钥 passphrase 都不会写入配置文件。插件仅在连接时从本地读取私钥;手动输入的密码和 passphrase 只会缓存在当前 Pi 进程的内存中。
|
|
266
277
|
|
|
267
278
|
## 安全与输出限制
|
|
268
279
|
|
|
@@ -279,17 +290,17 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
|
|
|
279
290
|
|
|
280
291
|
## 当前限制
|
|
281
292
|
|
|
282
|
-
目前只支持 SSH 直连,以及 `-p`、`-l` 参数。暂不读取 `~/.ssh/config
|
|
293
|
+
目前只支持 SSH 直连,以及 `-p`、`-l`、`-i` 参数。暂不读取 `~/.ssh/config` 或 ProxyJump;如需指定私钥,请显式使用 `-i`,不要依赖 SSH config 中的 `IdentityFile`。
|
|
283
294
|
|
|
284
295
|
## 版本发布
|
|
285
296
|
|
|
286
|
-
最新版本:[v0.1.
|
|
297
|
+
最新版本:[v0.1.8](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.8)
|
|
287
298
|
|
|
288
299
|
| 版本 | 日期 | 主要内容 |
|
|
289
300
|
|---|---|---|
|
|
301
|
+
| [0.1.8](CHANGELOG.md#018---2026-08-10) | 2026-08-10 | 显式私钥登录、跨端口服务器记忆与更短的 `remote` 工具名 |
|
|
290
302
|
| [0.1.7](CHANGELOG.md#017---2026-08-05) | 2026-08-05 | 新建、分叉和恢复 session 时自动恢复对应 SSH 工作区 |
|
|
291
303
|
| [0.1.6](CHANGELOG.md#016---2026-08-05) | 2026-08-05 | 有界远端读取、流式命令输出与每轮输出预算 |
|
|
292
|
-
| [0.1.5](CHANGELOG.md#015---2026-08-04) | 2026-08-04 | endpoint 级服务器记忆与自动上下文注入 |
|
|
293
304
|
|
|
294
305
|
完整的新增内容、行为变更和 Bug 修复记录请查看 [CHANGELOG.md](CHANGELOG.md)。
|
|
295
306
|
|
package/index.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* credentials, remote working directories, reconnection, and TCP forwarding.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { Client, type ClientChannel, type ConnectConfig, type SFTPWrapper } from "ssh2";
|
|
10
|
-
import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, writeFileSync, writeSync } from "node:fs";
|
|
11
|
-
import { dirname, join, posix, relative, sep } from "node:path";
|
|
9
|
+
import ssh2, { type Client as SshClient, type ClientChannel, type ConnectConfig, type SFTPWrapper } from "ssh2";
|
|
10
|
+
import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
11
|
+
import { dirname, isAbsolute, join, posix, relative, sep } from "node:path";
|
|
12
12
|
import { createServer, type Server, type Socket } from "node:net";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { Type } from "typebox";
|
|
@@ -33,21 +33,25 @@ import {
|
|
|
33
33
|
} from "@earendil-works/pi-coding-agent";
|
|
34
34
|
import { CURSOR_MARKER, Key, Text, matchesKey, truncateToWidth, type Component, type Focusable } from "@earendil-works/pi-tui";
|
|
35
35
|
|
|
36
|
+
const { Client, utils: ssh2Utils } = ssh2;
|
|
37
|
+
|
|
36
38
|
interface ParsedSsh {
|
|
37
39
|
host: string;
|
|
38
40
|
port: number;
|
|
39
41
|
username: string;
|
|
42
|
+
identityFile?: string;
|
|
40
43
|
label: string;
|
|
41
44
|
command: string;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
interface RemoteState extends ParsedSsh {
|
|
45
|
-
client:
|
|
48
|
+
client: SshClient;
|
|
46
49
|
cwd: string;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
interface CredentialCache {
|
|
50
53
|
passwords: Map<string, string>;
|
|
54
|
+
keyPassphrases: Map<string, string>;
|
|
51
55
|
resume?: { command: string; cwd: string; routeRemoteTools: boolean; forwards?: string[] };
|
|
52
56
|
}
|
|
53
57
|
|
|
@@ -56,12 +60,14 @@ interface RemoteEndpointConfig {
|
|
|
56
60
|
remoteCwd?: string;
|
|
57
61
|
forwards?: string[];
|
|
58
62
|
note?: string;
|
|
63
|
+
/** Legacy field migrated into serverMemories on the next config write. */
|
|
59
64
|
memory?: string;
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
interface RemoteConfig {
|
|
63
68
|
activeEndpoint?: string;
|
|
64
69
|
endpoints?: Record<string, RemoteEndpointConfig>;
|
|
70
|
+
serverMemories?: Record<string, string>;
|
|
65
71
|
displayLines?: number;
|
|
66
72
|
readMaxLines?: number;
|
|
67
73
|
readMaxBytes?: number;
|
|
@@ -104,10 +110,12 @@ const MIN_MODEL_OUTPUT_BYTES = 1024;
|
|
|
104
110
|
const OUTPUT_FOOTER_RESERVE_BYTES = 512;
|
|
105
111
|
const DEFAULT_REMOTE_TIMEOUT_SECONDS = 30;
|
|
106
112
|
const MAX_REMOTE_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
|
|
113
|
+
const MAX_PRIVATE_KEY_BYTES = 1024 * 1024;
|
|
107
114
|
const SESSION_STATE_ENTRY_TYPE = "pi-ssh-remote-state";
|
|
108
115
|
const CACHE_KEY = "__piHpcCredentialCacheV1";
|
|
109
116
|
const cacheHost = globalThis as typeof globalThis & { [CACHE_KEY]?: CredentialCache };
|
|
110
|
-
const credentialCache = cacheHost[CACHE_KEY] ??= { passwords: new Map<string, string>() };
|
|
117
|
+
const credentialCache = cacheHost[CACHE_KEY] ??= { passwords: new Map<string, string>(), keyPassphrases: new Map<string, string>() };
|
|
118
|
+
credentialCache.keyPassphrases ??= new Map<string, string>();
|
|
111
119
|
|
|
112
120
|
function shellWords(input: string): string[] {
|
|
113
121
|
const words: string[] = [];
|
|
@@ -132,28 +140,47 @@ function parseSshCommand(command: string): ParsedSsh {
|
|
|
132
140
|
if (args[0] !== "ssh") throw new Error("Command must start with ssh, for example: ssh root@host -p 22");
|
|
133
141
|
let port = 22;
|
|
134
142
|
let username = process.env.USER || "root";
|
|
143
|
+
let identityFile: string | undefined;
|
|
135
144
|
let target: string | undefined;
|
|
136
145
|
for (let i = 1; i < args.length; i++) {
|
|
137
146
|
const arg = args[i]!;
|
|
138
147
|
if (arg === "-p") { port = Number(args[++i]); continue; }
|
|
139
148
|
if (arg.startsWith("-p") && arg.length > 2) { port = Number(arg.slice(2)); continue; }
|
|
140
149
|
if (arg === "-l") { username = args[++i] || username; continue; }
|
|
141
|
-
if (arg
|
|
150
|
+
if (arg === "-i") {
|
|
151
|
+
if (identityFile !== undefined) throw new Error("Only one SSH identity file may be specified");
|
|
152
|
+
identityFile = args[++i];
|
|
153
|
+
if (!identityFile) throw new Error("SSH option -i requires a private key path");
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (arg.startsWith("-i") && arg.length > 2) {
|
|
157
|
+
if (identityFile !== undefined) throw new Error("Only one SSH identity file may be specified");
|
|
158
|
+
identityFile = arg.slice(2);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (arg.startsWith("-")) throw new Error(`Unsupported SSH option ${arg}; only -p, -l, and -i are currently supported`);
|
|
142
162
|
if (!target) target = arg;
|
|
143
163
|
else throw new Error("Unexpected extra argument in SSH command");
|
|
144
164
|
}
|
|
145
165
|
if (!target || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid SSH host or port");
|
|
166
|
+
if (identityFile && identityFile !== "~" && !identityFile.startsWith("~/") && !isAbsolute(identityFile)) {
|
|
167
|
+
throw new Error("SSH identity file must use an absolute path or ~/...");
|
|
168
|
+
}
|
|
146
169
|
const at = target.lastIndexOf("@");
|
|
147
170
|
const host = at >= 0 ? target.slice(at + 1) : target;
|
|
148
171
|
if (at >= 0) username = target.slice(0, at);
|
|
149
172
|
if (!host || !username) throw new Error("Invalid SSH username or host");
|
|
150
|
-
return { host, port, username, label: `${username}@${host}:${port}`, command };
|
|
173
|
+
return { host, port, username, ...(identityFile ? { identityFile } : {}), label: `${username}@${host}:${port}`, command };
|
|
151
174
|
}
|
|
152
175
|
|
|
153
176
|
function cacheId(config: ParsedSsh): string {
|
|
154
177
|
return `${config.username}@${config.host}:${config.port}`;
|
|
155
178
|
}
|
|
156
179
|
|
|
180
|
+
function serverMemoryId(config: ParsedSsh): string {
|
|
181
|
+
return `${config.username}@${config.host}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
157
184
|
function getCachedPassword(config: ParsedSsh): string | undefined {
|
|
158
185
|
return credentialCache.passwords.get(cacheId(config));
|
|
159
186
|
}
|
|
@@ -166,6 +193,60 @@ function deleteCachedPassword(config: ParsedSsh): void {
|
|
|
166
193
|
credentialCache.passwords.delete(cacheId(config));
|
|
167
194
|
}
|
|
168
195
|
|
|
196
|
+
function resolveIdentityPath(config: ParsedSsh): string {
|
|
197
|
+
if (!config.identityFile) throw new Error("No SSH identity file is configured");
|
|
198
|
+
if (config.identityFile === "~" || config.identityFile.startsWith("~/")) {
|
|
199
|
+
const home = process.env.HOME;
|
|
200
|
+
if (!home) throw new Error("Cannot expand SSH identity path because HOME is not set");
|
|
201
|
+
return config.identityFile === "~" ? home : join(home, config.identityFile.slice(2));
|
|
202
|
+
}
|
|
203
|
+
return config.identityFile;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function keyPassphraseId(config: ParsedSsh): string {
|
|
207
|
+
return `${cacheId(config)}|${resolveIdentityPath(config)}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function getCachedKeyPassphrase(config: ParsedSsh): string | undefined {
|
|
211
|
+
return credentialCache.keyPassphrases.get(keyPassphraseId(config));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function setCachedKeyPassphrase(config: ParsedSsh, passphrase: string): void {
|
|
215
|
+
credentialCache.keyPassphrases.set(keyPassphraseId(config), passphrase);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function deleteCachedKeyPassphrase(config: ParsedSsh): void {
|
|
219
|
+
if (config.identityFile) credentialCache.keyPassphrases.delete(keyPassphraseId(config));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function readPrivateKey(config: ParsedSsh): Buffer {
|
|
223
|
+
const path = resolveIdentityPath(config);
|
|
224
|
+
let stat;
|
|
225
|
+
try { stat = statSync(path); }
|
|
226
|
+
catch (error) { throw new Error(`Cannot access SSH private key ${path}: ${(error as Error).message}`); }
|
|
227
|
+
if (!stat.isFile()) throw new Error(`SSH private key is not a regular file: ${path}`);
|
|
228
|
+
if (stat.size > MAX_PRIVATE_KEY_BYTES) throw new Error(`SSH private key exceeds the ${MAX_PRIVATE_KEY_BYTES}-byte limit: ${path}`);
|
|
229
|
+
try { return readFileSync(path); }
|
|
230
|
+
catch (error) { throw new Error(`Cannot read SSH private key ${path}: ${(error as Error).message}`); }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function parsePrivateKey(keyData: Buffer, passphrase?: string): any | Error {
|
|
234
|
+
let parsed: any;
|
|
235
|
+
try { parsed = ssh2Utils.parseKey(keyData, passphrase); }
|
|
236
|
+
catch (error) { return error as Error; }
|
|
237
|
+
if (parsed instanceof Error) return parsed;
|
|
238
|
+
const keys = Array.isArray(parsed) ? parsed : [parsed];
|
|
239
|
+
const privateKeys = keys.filter((key) => key?.isPrivateKey?.());
|
|
240
|
+
if (privateKeys.length !== 1) {
|
|
241
|
+
return new Error(privateKeys.length ? "SSH identity files containing multiple private keys are not supported" : "SSH identity file does not contain a private key");
|
|
242
|
+
}
|
|
243
|
+
return privateKeys[0];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isPassphraseError(error: Error): boolean {
|
|
247
|
+
return /passphrase|encrypted private/i.test(error.message);
|
|
248
|
+
}
|
|
249
|
+
|
|
169
250
|
function parseForwardSpec(value: string): ForwardSpec {
|
|
170
251
|
const match = value.match(/^(\d+):([^:]+):(\d+)$/);
|
|
171
252
|
if (!match) throw new Error(`Invalid port-forward specification: ${value}; expected LOCAL_PORT:REMOTE_HOST:REMOTE_PORT`);
|
|
@@ -241,11 +322,25 @@ function commandFromEndpointKey(key: string): string | undefined {
|
|
|
241
322
|
|
|
242
323
|
function normalizeRemoteConfig(config: RemoteConfig): RemoteConfig {
|
|
243
324
|
const endpoints = { ...(config.endpoints ?? {}) };
|
|
244
|
-
|
|
325
|
+
const serverMemories = Object.fromEntries(
|
|
326
|
+
Object.entries(config.serverMemories ?? {})
|
|
327
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === "string" && Boolean(entry[1].trim()))
|
|
328
|
+
.map(([key, memory]) => [key, memory.trim()]),
|
|
329
|
+
);
|
|
330
|
+
const endpointEntries = Object.entries(endpoints).sort(([left], [right]) =>
|
|
331
|
+
left === config.activeEndpoint ? -1 : right === config.activeEndpoint ? 1 : 0,
|
|
332
|
+
);
|
|
333
|
+
for (const [key, endpoint] of endpointEntries) {
|
|
334
|
+
const command = endpoint.sshCommand || commandFromEndpointKey(key);
|
|
335
|
+
const { memory: legacyMemory, ...endpointWithoutMemory } = endpoint;
|
|
245
336
|
endpoints[key] = {
|
|
246
|
-
...
|
|
247
|
-
...(
|
|
337
|
+
...endpointWithoutMemory,
|
|
338
|
+
...(command ? { sshCommand: command } : {}),
|
|
248
339
|
};
|
|
340
|
+
if (legacyMemory?.trim() && command) {
|
|
341
|
+
try { serverMemories[serverMemoryId(parseSshCommand(command))] ??= legacyMemory.trim(); }
|
|
342
|
+
catch {}
|
|
343
|
+
}
|
|
249
344
|
}
|
|
250
345
|
|
|
251
346
|
let activeEndpoint = config.activeEndpoint;
|
|
@@ -279,6 +374,7 @@ function normalizeRemoteConfig(config: RemoteConfig): RemoteConfig {
|
|
|
279
374
|
return {
|
|
280
375
|
...(activeEndpoint ? { activeEndpoint } : {}),
|
|
281
376
|
...(Object.keys(endpoints).length ? { endpoints } : {}),
|
|
377
|
+
...(Object.keys(serverMemories).length ? { serverMemories } : {}),
|
|
282
378
|
...(displayLines !== undefined ? { displayLines } : {}),
|
|
283
379
|
...(readMaxLines !== undefined ? { readMaxLines } : {}),
|
|
284
380
|
...(readMaxBytes !== undefined ? { readMaxBytes } : {}),
|
|
@@ -317,13 +413,13 @@ function endpointDisplayLabel(endpoint: ParsedSsh, config = loadRemoteConfig()):
|
|
|
317
413
|
}
|
|
318
414
|
|
|
319
415
|
function endpointMemory(endpoint: ParsedSsh, config = loadRemoteConfig()): string | undefined {
|
|
320
|
-
return
|
|
416
|
+
return config.serverMemories?.[serverMemoryId(endpoint)]?.trim() || undefined;
|
|
321
417
|
}
|
|
322
418
|
|
|
323
419
|
function remoteSystemPrompt(systemPrompt: string, localCwd: string, remote: RemoteState): string {
|
|
324
420
|
return systemPrompt.replace(
|
|
325
421
|
`Current working directory: ${localCwd}`,
|
|
326
|
-
`Current working directory: ${remote.cwd} (via SSH ${endpointDisplayLabel(remote)}). All read, write, edit, bash, and user shell operations run on this remote server. Use
|
|
422
|
+
`Current working directory: ${remote.cwd} (via SSH ${endpointDisplayLabel(remote)}). All read, write, edit, bash, and user shell operations run on this remote server. Use remote with action disconnect to return to the local environment when requested.`,
|
|
327
423
|
);
|
|
328
424
|
}
|
|
329
425
|
|
|
@@ -346,6 +442,24 @@ function saveEndpointConfig(command: string, updates: RemoteEndpointConfig, make
|
|
|
346
442
|
});
|
|
347
443
|
}
|
|
348
444
|
|
|
445
|
+
function saveServerMemory(command: string, memory: string | undefined): void {
|
|
446
|
+
const config = loadRemoteConfig();
|
|
447
|
+
const parsed = parseSshCommand(command);
|
|
448
|
+
const endpointKey = cacheId(parsed);
|
|
449
|
+
const memoryKey = serverMemoryId(parsed);
|
|
450
|
+
const serverMemories = { ...(config.serverMemories ?? {}) };
|
|
451
|
+
if (memory) serverMemories[memoryKey] = memory;
|
|
452
|
+
else delete serverMemories[memoryKey];
|
|
453
|
+
saveRemoteConfig({
|
|
454
|
+
...config,
|
|
455
|
+
serverMemories,
|
|
456
|
+
endpoints: {
|
|
457
|
+
...(config.endpoints ?? {}),
|
|
458
|
+
[endpointKey]: { ...endpointConfig(config, command), sshCommand: command },
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
349
463
|
function loadKnownHosts(): Record<string, string> {
|
|
350
464
|
try { return JSON.parse(readFileSync(KNOWN_HOSTS_FILE, "utf8")); }
|
|
351
465
|
catch { return {}; }
|
|
@@ -518,15 +632,16 @@ function probeFingerprint(config: ParsedSsh): Promise<string> {
|
|
|
518
632
|
});
|
|
519
633
|
}
|
|
520
634
|
|
|
521
|
-
|
|
635
|
+
type SshAuthentication = Partial<Pick<ConnectConfig, "password" | "privateKey" | "passphrase" | "agent">>;
|
|
636
|
+
|
|
637
|
+
function connect(config: ParsedSsh, authentication: SshAuthentication, fingerprint: string): Promise<SshClient> {
|
|
522
638
|
return new Promise((resolve, reject) => {
|
|
523
639
|
const client = new Client();
|
|
524
640
|
const options: ConnectConfig = {
|
|
525
641
|
host: config.host,
|
|
526
642
|
port: config.port,
|
|
527
643
|
username: config.username,
|
|
528
|
-
...
|
|
529
|
-
...(process.env.SSH_AUTH_SOCK ? { agent: process.env.SSH_AUTH_SOCK } : {}),
|
|
644
|
+
...authentication,
|
|
530
645
|
readyTimeout: 12000,
|
|
531
646
|
keepaliveInterval: 15000,
|
|
532
647
|
keepaliveCountMax: 3,
|
|
@@ -540,7 +655,7 @@ function connect(config: ParsedSsh, password: string | undefined, fingerprint: s
|
|
|
540
655
|
}
|
|
541
656
|
|
|
542
657
|
function execRemote(
|
|
543
|
-
client:
|
|
658
|
+
client: SshClient,
|
|
544
659
|
command: string,
|
|
545
660
|
allowFailure = false,
|
|
546
661
|
timeoutSeconds = DEFAULT_REMOTE_TIMEOUT_SECONDS,
|
|
@@ -574,7 +689,7 @@ function execRemote(
|
|
|
574
689
|
}
|
|
575
690
|
|
|
576
691
|
function execRemoteLimited(
|
|
577
|
-
client:
|
|
692
|
+
client: SshClient,
|
|
578
693
|
command: string,
|
|
579
694
|
timeoutSeconds: number,
|
|
580
695
|
maxLines: number,
|
|
@@ -620,11 +735,11 @@ function execRemoteLimited(
|
|
|
620
735
|
});
|
|
621
736
|
}
|
|
622
737
|
|
|
623
|
-
function getSftp(client:
|
|
738
|
+
function getSftp(client: SshClient): Promise<SFTPWrapper> {
|
|
624
739
|
return new Promise((resolve, reject) => client.sftp((error, sftp) => error ? reject(error) : resolve(sftp)));
|
|
625
740
|
}
|
|
626
741
|
|
|
627
|
-
async function withSftp<T>(client:
|
|
742
|
+
async function withSftp<T>(client: SshClient, operation: (sftp: SFTPWrapper) => Promise<T>): Promise<T> {
|
|
628
743
|
const sftp = await getSftp(client);
|
|
629
744
|
try { return await operation(sftp); }
|
|
630
745
|
finally { sftp.end(); }
|
|
@@ -635,10 +750,10 @@ function isReconnectable(error: unknown): boolean {
|
|
|
635
750
|
return /channel open failure|open failed|not connected|no response|econnreset|econnrefused|etimedout|ehostunreach|epipe|connection (?:lost|closed)|socket.*closed|client is not connected/i.test(message);
|
|
636
751
|
}
|
|
637
752
|
|
|
638
|
-
class
|
|
753
|
+
class SecretInput implements Component, Focusable {
|
|
639
754
|
focused = false;
|
|
640
755
|
private value = "";
|
|
641
|
-
constructor(private done: (value: string | null) => void, private renderNow: () => void) {}
|
|
756
|
+
constructor(private label: string, private done: (value: string | null) => void, private renderNow: () => void) {}
|
|
642
757
|
handleInput(data: string): void {
|
|
643
758
|
if (matchesKey(data, Key.enter)) return this.done(this.value);
|
|
644
759
|
if (matchesKey(data, Key.escape)) return this.done(null);
|
|
@@ -651,15 +766,19 @@ class PasswordInput implements Component, Focusable {
|
|
|
651
766
|
this.renderNow();
|
|
652
767
|
}
|
|
653
768
|
render(width: number): string[] {
|
|
654
|
-
return [truncateToWidth(
|
|
769
|
+
return [truncateToWidth(`${this.label}: ${"•".repeat([...this.value].length)}${this.focused ? CURSOR_MARKER : ""}\x1b[7m \x1b[27m`, width, "")];
|
|
655
770
|
}
|
|
656
771
|
invalidate(): void {}
|
|
657
772
|
}
|
|
658
773
|
|
|
659
|
-
async function
|
|
660
|
-
if (ctx.mode !== "tui") return (await ctx.ui.input(
|
|
774
|
+
async function askSecret(ctx: any, label: string, placeholder: string): Promise<string | null> {
|
|
775
|
+
if (ctx.mode !== "tui") return (await ctx.ui.input(`${label}:`, placeholder)) ?? null;
|
|
661
776
|
return ctx.ui.custom<string | null>((tui: any, _theme: any, _keys: any, done: (value: string | null) => void) =>
|
|
662
|
-
new
|
|
777
|
+
new SecretInput(label, done, () => tui.requestRender()));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function askPassword(ctx: any): Promise<string | null> {
|
|
781
|
+
return askSecret(ctx, "SSH password", "password");
|
|
663
782
|
}
|
|
664
783
|
|
|
665
784
|
export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
@@ -682,6 +801,33 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
682
801
|
const configuredForwards = (command: string): string[] =>
|
|
683
802
|
endpointConfig(loadRemoteConfig(), command).forwards ?? [];
|
|
684
803
|
|
|
804
|
+
const standardAuthentication = (password?: string): SshAuthentication => ({
|
|
805
|
+
...(password ? { password } : {}),
|
|
806
|
+
...(process.env.SSH_AUTH_SOCK ? { agent: process.env.SSH_AUTH_SOCK } : {}),
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
const privateKeyAuthentication = async (parsed: ParsedSsh, ctx?: any): Promise<SshAuthentication> => {
|
|
810
|
+
const keyData = readPrivateKey(parsed);
|
|
811
|
+
let passphrase = getCachedKeyPassphrase(parsed);
|
|
812
|
+
let privateKey = parsePrivateKey(keyData, passphrase);
|
|
813
|
+
if (privateKey instanceof Error && isPassphraseError(privateKey)) {
|
|
814
|
+
if (passphrase) deleteCachedKeyPassphrase(parsed);
|
|
815
|
+
if (!ctx) throw new Error(`SSH private key ${resolveIdentityPath(parsed)} requires its passphrase again; reconnect interactively`);
|
|
816
|
+
passphrase = await askSecret(ctx, `Passphrase for ${parsed.identityFile}`, "private key passphrase") ?? undefined;
|
|
817
|
+
if (!passphrase) throw new Error("No SSH private key passphrase was provided");
|
|
818
|
+
privateKey = parsePrivateKey(keyData, passphrase);
|
|
819
|
+
if (privateKey instanceof Error) {
|
|
820
|
+
deleteCachedKeyPassphrase(parsed);
|
|
821
|
+
throw new Error(`Could not unlock SSH private key ${resolveIdentityPath(parsed)}: ${privateKey.message}`);
|
|
822
|
+
}
|
|
823
|
+
setCachedKeyPassphrase(parsed, passphrase);
|
|
824
|
+
}
|
|
825
|
+
if (privateKey instanceof Error) {
|
|
826
|
+
throw new Error(`Invalid SSH private key ${resolveIdentityPath(parsed)}: ${privateKey.message}`);
|
|
827
|
+
}
|
|
828
|
+
return { privateKey: keyData, ...(passphrase ? { passphrase } : {}) };
|
|
829
|
+
};
|
|
830
|
+
|
|
685
831
|
const mapPath = (path: string): string => {
|
|
686
832
|
if (!remote) return path;
|
|
687
833
|
if (path === localCwd) return remote.cwd;
|
|
@@ -802,11 +948,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
802
948
|
});
|
|
803
949
|
};
|
|
804
950
|
|
|
805
|
-
const establish = async (parsed: ParsedSsh,
|
|
951
|
+
const establish = async (parsed: ParsedSsh, authentication: SshAuthentication, cwd: string): Promise<RemoteState> => {
|
|
806
952
|
const key = `${parsed.host}:${parsed.port}`;
|
|
807
953
|
const fingerprint = loadKnownHosts()[key];
|
|
808
954
|
if (!fingerprint) throw new Error(`Host ${key} is not trusted; connect interactively with /remote first`);
|
|
809
|
-
const client = await connect(parsed,
|
|
955
|
+
const client = await connect(parsed, authentication, fingerprint);
|
|
810
956
|
try {
|
|
811
957
|
const cdCommand = cwd === FALLBACK_REMOTE_CWD ? "cd -- ~" : `cd -- ${quote(cwd)}`;
|
|
812
958
|
const resolved = (await execRemote(client, `${cdCommand} && pwd -P`)).toString().trim();
|
|
@@ -827,8 +973,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
827
973
|
const parsed = parseSshCommand(source.command);
|
|
828
974
|
const password = getCachedPassword(parsed);
|
|
829
975
|
reconnectPromise = (async () => {
|
|
976
|
+
const authentication = parsed.identityFile
|
|
977
|
+
? await privateKeyAuthentication(parsed)
|
|
978
|
+
: standardAuthentication(password);
|
|
830
979
|
const oldClient = remote?.client;
|
|
831
|
-
const next = await establish(parsed,
|
|
980
|
+
const next = await establish(parsed, authentication, source.cwd);
|
|
832
981
|
remote = next;
|
|
833
982
|
routeRemoteTools = resumeRouting;
|
|
834
983
|
credentialCache.resume = { command: parsed.command, cwd: next.cwd, routeRemoteTools, forwards: credentialCache.resume?.forwards };
|
|
@@ -842,7 +991,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
842
991
|
return reconnectPromise;
|
|
843
992
|
}
|
|
844
993
|
|
|
845
|
-
const withReconnect = async <T>(operation: (client:
|
|
994
|
+
const withReconnect = async <T>(operation: (client: SshClient) => Promise<T>): Promise<T> => {
|
|
846
995
|
if (!remote) throw new Error("SSH remote is not connected");
|
|
847
996
|
try { return await operation(remote.client); }
|
|
848
997
|
catch (error) {
|
|
@@ -908,24 +1057,28 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
908
1057
|
let password = getCachedPassword(parsed);
|
|
909
1058
|
ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("warning", `SSH remote connecting ${endpointDisplayLabel(parsed)}…`));
|
|
910
1059
|
try {
|
|
1060
|
+
let authentication = parsed.identityFile
|
|
1061
|
+
? await privateKeyAuthentication(parsed, ctx)
|
|
1062
|
+
: standardAuthentication(password);
|
|
911
1063
|
let next: RemoteState;
|
|
912
1064
|
try {
|
|
913
|
-
next = await establish(parsed,
|
|
1065
|
+
next = await establish(parsed, authentication, cwd ?? configuredCwd(command));
|
|
914
1066
|
} catch (error) {
|
|
915
|
-
if (!/authentication methods failed|authentication failure/i.test((error as Error).message)) throw error;
|
|
1067
|
+
if (parsed.identityFile || !/authentication methods failed|authentication failure/i.test((error as Error).message)) throw error;
|
|
916
1068
|
password = await askPassword(ctx) ?? undefined;
|
|
917
1069
|
if (!password) {
|
|
918
1070
|
lastConnectionError = "No SSH password was provided and SSH agent authentication failed";
|
|
919
1071
|
status(ctx);
|
|
920
1072
|
return null;
|
|
921
1073
|
}
|
|
922
|
-
|
|
1074
|
+
authentication = standardAuthentication(password);
|
|
1075
|
+
next = await establish(parsed, authentication, cwd ?? configuredCwd(command));
|
|
923
1076
|
}
|
|
924
1077
|
const previous = remote?.client;
|
|
925
1078
|
remote = next;
|
|
926
1079
|
routeRemoteTools = true;
|
|
927
1080
|
previous?.end();
|
|
928
|
-
if (password) setCachedPassword(parsed, password);
|
|
1081
|
+
if (!parsed.identityFile && password) setCachedPassword(parsed, password);
|
|
929
1082
|
credentialCache.resume = { command, cwd: next.cwd, routeRemoteTools, forwards: [] };
|
|
930
1083
|
lastCommand = command;
|
|
931
1084
|
lastConnectionError = undefined;
|
|
@@ -935,7 +1088,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
935
1088
|
ctx.ui.notify(`SSH remote connected: ${endpointDisplayLabel(next)}:${next.cwd}`, "info");
|
|
936
1089
|
return next;
|
|
937
1090
|
} catch (error) {
|
|
938
|
-
deleteCachedPassword(parsed);
|
|
1091
|
+
if (!parsed.identityFile) deleteCachedPassword(parsed);
|
|
939
1092
|
remote = null;
|
|
940
1093
|
lastConnectionError = (error as Error).message;
|
|
941
1094
|
status(ctx);
|
|
@@ -948,7 +1101,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
948
1101
|
if (remote) return remote;
|
|
949
1102
|
if (credentialCache.resume) return reconnectRemote();
|
|
950
1103
|
const command = lastCommand || activeSshCommand();
|
|
951
|
-
if (!command) throw new Error("No SSH endpoint configured; use /remote ssh USER@HOST -p PORT");
|
|
1104
|
+
if (!command) throw new Error("No SSH endpoint configured; use /remote ssh USER@HOST -p PORT [-i KEY]");
|
|
952
1105
|
const state = await connectInteractive(command, ctx, configuredCwd(command));
|
|
953
1106
|
if (!state) throw new Error("SSH remote connection was cancelled or failed");
|
|
954
1107
|
return state;
|
|
@@ -1001,26 +1154,28 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1001
1154
|
status(ctx);
|
|
1002
1155
|
};
|
|
1003
1156
|
|
|
1004
|
-
const disconnect = (ctx: any,
|
|
1157
|
+
const disconnect = (ctx: any, forgetCredentials = false) => {
|
|
1005
1158
|
const previous = remote;
|
|
1006
1159
|
remote = null;
|
|
1007
1160
|
routeRemoteTools = false;
|
|
1008
1161
|
reconnectPromise = null;
|
|
1009
1162
|
credentialCache.resume = undefined;
|
|
1010
1163
|
void stopForwards();
|
|
1011
|
-
if (
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1164
|
+
if (forgetCredentials) {
|
|
1165
|
+
const configured = previous ?? (() => {
|
|
1166
|
+
const command = activeSshCommand();
|
|
1167
|
+
if (!command) return undefined;
|
|
1168
|
+
try { return parseSshCommand(command); } catch { return undefined; }
|
|
1169
|
+
})();
|
|
1170
|
+
if (configured) {
|
|
1171
|
+
deleteCachedPassword(configured);
|
|
1172
|
+
deleteCachedKeyPassphrase(configured);
|
|
1018
1173
|
}
|
|
1019
1174
|
}
|
|
1020
1175
|
previous?.client.end();
|
|
1021
1176
|
status(ctx);
|
|
1022
1177
|
persistSessionRemoteState();
|
|
1023
|
-
ctx.ui.notify(
|
|
1178
|
+
ctx.ui.notify(forgetCredentials ? "SSH remote disconnected and cached credentials cleared" : "SSH remote mode disabled (credentials remain cached in memory only)", "info");
|
|
1024
1179
|
};
|
|
1025
1180
|
|
|
1026
1181
|
const detectRemoteMimeType = async (path: string): Promise<string | undefined> => {
|
|
@@ -1137,22 +1292,22 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1137
1292
|
});
|
|
1138
1293
|
|
|
1139
1294
|
pi.registerTool({
|
|
1140
|
-
name: "
|
|
1141
|
-
label: "
|
|
1142
|
-
description: "Connect, reconnect, annotate endpoints, manage server-specific memory, change the persistent remote working directory, inspect, forward ports, run remote SSH commands, or disconnect the configured SSH environment. Exec output is streamed to bounded buffers; model output defaults to the last 200 lines or 8KB, while complete oversized output is saved locally. Passwords are never accepted as arguments and are cached only in process memory.",
|
|
1295
|
+
name: "remote",
|
|
1296
|
+
label: "Remote",
|
|
1297
|
+
description: "Connect, reconnect, annotate endpoints, manage server-specific memory, change the persistent remote working directory, inspect, forward ports, run remote SSH commands, or disconnect the configured SSH environment. Connections support SSH agent, password, or an explicit local private key with -i. Exec output is streamed to bounded buffers; model output defaults to the last 200 lines or 8KB, while complete oversized output is saved locally. Passwords and key passphrases are never accepted as arguments and are cached only in process memory.",
|
|
1143
1298
|
promptSnippet: "Control the configured remote SSH connection, endpoint note and memory, working directory, and local port forwarding",
|
|
1144
1299
|
promptGuidelines: [
|
|
1145
|
-
"Use
|
|
1146
|
-
"Use
|
|
1147
|
-
`Always set timeout for
|
|
1148
|
-
"Keep
|
|
1149
|
-
"Use
|
|
1300
|
+
"Use remote when the user asks the agent to enter, reconnect, inspect, or leave a remote SSH environment.",
|
|
1301
|
+
"Use remote with action chdir when the user asks to change the remote working directory; do not emulate a persistent directory change with action exec and a one-command cwd.",
|
|
1302
|
+
`Always set timeout for remote exec commands; it defaults to ${DEFAULT_REMOTE_TIMEOUT_SECONDS} seconds when omitted.`,
|
|
1303
|
+
"Keep remote exec output narrow with tail, sed, rg limits, or similarly bounded commands; never cat large logs or emit broad file listings.",
|
|
1304
|
+
"Use remote with action disconnect after remote work when the user asks to return to the local environment.",
|
|
1150
1305
|
],
|
|
1151
1306
|
parameters: Type.Object({
|
|
1152
1307
|
action: StringEnum(["connect", "reconnect", "status", "disconnect", "forget", "forward", "unforward", "exec", "chdir", "note", "memory"] as const),
|
|
1153
|
-
command: Type.Optional(Type.String({ description: "SSH command for connect, such as ssh root@host -p 22; optionally selects the endpoint for note or memory" })),
|
|
1308
|
+
command: Type.Optional(Type.String({ description: "SSH command for connect, such as ssh root@host -p 22 or ssh -i ~/.ssh/id_ed25519 root@host; optionally selects the endpoint for note or memory" })),
|
|
1154
1309
|
note: Type.Optional(Type.String({ description: "Endpoint note for the note action; omit or use an empty string to clear it" })),
|
|
1155
|
-
memory: Type.Optional(Type.String({ description: "Persistent
|
|
1310
|
+
memory: Type.Optional(Type.String({ description: "Persistent context shared by the endpoint's user@host across SSH ports; omit or use an empty string to clear it" })),
|
|
1156
1311
|
cwd: Type.Optional(Type.String({ description: "Remote working directory; required for chdir, and a one-command override for exec" })),
|
|
1157
1312
|
forwards: Type.Optional(Type.String({ description: "Space-separated LOCAL_PORT:REMOTE_HOST:REMOTE_PORT mappings; defaults to ssh-remote-config.json" })),
|
|
1158
1313
|
remoteCommand: Type.Optional(Type.String({ description: "Remote shell command for the exec action" })),
|
|
@@ -1167,7 +1322,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1167
1322
|
}
|
|
1168
1323
|
if (params.action === "disconnect" || params.action === "forget") {
|
|
1169
1324
|
disconnect(ctx, params.action === "forget");
|
|
1170
|
-
return { content: [{ type: "text", text: params.action === "forget" ? "Disconnected and forgot the cached
|
|
1325
|
+
return { content: [{ type: "text", text: params.action === "forget" ? "Disconnected and forgot the cached credentials." : "Disconnected from SSH remote and returned to local tools." }], details: { connected: false } };
|
|
1171
1326
|
}
|
|
1172
1327
|
if (params.action === "reconnect") {
|
|
1173
1328
|
if (!remote && !credentialCache.resume) throw new Error("No SSH remote connection is available to reconnect");
|
|
@@ -1212,10 +1367,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1212
1367
|
};
|
|
1213
1368
|
}
|
|
1214
1369
|
const memory = params.memory?.trim() || undefined;
|
|
1215
|
-
|
|
1370
|
+
const server = serverMemoryId(parseSshCommand(command));
|
|
1371
|
+
saveServerMemory(command, memory);
|
|
1216
1372
|
return {
|
|
1217
|
-
content: [{ type: "text", text: memory ? `SSH remote server memory updated (${
|
|
1218
|
-
details: { endpoint: label, memory },
|
|
1373
|
+
content: [{ type: "text", text: memory ? `SSH remote server memory updated (${server}). It applies to every port for this user and host.` : `SSH remote server memory cleared (${server}).` }],
|
|
1374
|
+
details: { endpoint: label, server, memory },
|
|
1219
1375
|
};
|
|
1220
1376
|
}
|
|
1221
1377
|
if (params.action === "exec") {
|
|
@@ -1262,7 +1418,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1262
1418
|
});
|
|
1263
1419
|
|
|
1264
1420
|
pi.registerCommand("remote", {
|
|
1265
|
-
description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] | config | use USER@HOST:PORT | config note TEXT|--clear | config memory TEXT|--clear | config cwd PATH | config display-lines N | config read-max-lines|read-max-bytes|exec-max-lines|exec-max-bytes|turn-max-bytes N | forward [MAPPINGS] | unforward | exec [--timeout SECONDS] [--lines N] COMMAND | cd PATH | status | reload | off | forget",
|
|
1421
|
+
description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] [-i KEY] | config | use USER@HOST:PORT | config note TEXT|--clear | config memory TEXT|--clear | config cwd PATH | config display-lines N | config read-max-lines|read-max-bytes|exec-max-lines|exec-max-bytes|turn-max-bytes N | forward [MAPPINGS] | unforward | exec [--timeout SECONDS] [--lines N] COMMAND | cd PATH | status | reload | off | forget",
|
|
1266
1422
|
handler: async (args, ctx) => {
|
|
1267
1423
|
const input = args.trim().replace(/^\/?remote(?:\s+|$)/i, "").trim();
|
|
1268
1424
|
const action = input.toLowerCase();
|
|
@@ -1270,7 +1426,9 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1270
1426
|
const config = loadRemoteConfig();
|
|
1271
1427
|
const rows = Object.entries(config.endpoints ?? {}).map(([key, endpoint]) => {
|
|
1272
1428
|
const active = key === config.activeEndpoint ? "*" : " ";
|
|
1273
|
-
|
|
1429
|
+
const command = endpoint.sshCommand || commandFromEndpointKey(key);
|
|
1430
|
+
const memory = command ? endpointMemory(parseSshCommand(command), config) : undefined;
|
|
1431
|
+
return `${active} ${key}\n note: ${endpoint.note || "none"}\n memory: ${memory || "none"}\n SSH: ${endpoint.sshCommand}\n cwd: ${endpoint.remoteCwd || FALLBACK_REMOTE_CWD}\n forward: ${endpoint.forwards?.join(", ") || "none"}`;
|
|
1274
1432
|
});
|
|
1275
1433
|
const limits = configuredOutputLimits(config);
|
|
1276
1434
|
ctx.ui.notify(`SSH remote configuration: ${REMOTE_CONFIG_FILE}\nDisplay lines: ${configuredDisplayLines(config)}\nRead output: ${limits.readMaxLines} lines / ${formatSize(limits.readMaxBytes)}\nExec output: ${limits.execMaxLines} lines / ${formatSize(limits.execMaxBytes)}\nPer-turn output: ${formatSize(limits.turnMaxBytes)}\n${rows.join("\n") || "No saved endpoints"}`, "info");
|
|
@@ -1330,8 +1488,9 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1330
1488
|
const command = lastCommand || activeSshCommand();
|
|
1331
1489
|
if (!command) { ctx.ui.notify("Configure an SSH endpoint first", "error"); return; }
|
|
1332
1490
|
const memory = value.toLowerCase() === "--clear" ? undefined : value;
|
|
1333
|
-
|
|
1334
|
-
|
|
1491
|
+
const server = serverMemoryId(parseSshCommand(command));
|
|
1492
|
+
saveServerMemory(command, memory);
|
|
1493
|
+
ctx.ui.notify(memory ? `SSH remote server memory updated (${server}); it applies to every port` : `SSH remote server memory cleared (${server})`, "info");
|
|
1335
1494
|
return;
|
|
1336
1495
|
}
|
|
1337
1496
|
if (/^config\s+display-lines\s+/i.test(input)) {
|