pi-ssh-remote 0.1.6 → 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 +34 -1
- package/README.md +24 -14
- package/README.zh-CN.md +26 -15
- package/index.ts +341 -72
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,37 @@ 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
|
+
|
|
37
|
+
## [0.1.7] - 2026-08-05
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- Added non-secret, session-scoped SSH workspace state for endpoint, remote directory, tool-routing mode, and port forwards.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- `/new` now inherits the active SSH workspace, forks and clones retain their source workspace, and `/resume` restores the selected historical session's recorded workspace.
|
|
46
|
+
- Session changes close the previous transport cleanly and reconnect the target workspace without storing passwords or other credentials in session files.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- None.
|
|
51
|
+
|
|
21
52
|
## [0.1.6] - 2026-08-05
|
|
22
53
|
|
|
23
54
|
### Added
|
|
@@ -130,7 +161,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
|
|
|
130
161
|
|
|
131
162
|
- None.
|
|
132
163
|
|
|
133
|
-
[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
|
|
166
|
+
[0.1.7]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.6...v0.1.7
|
|
134
167
|
[0.1.6]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.5...v0.1.6
|
|
135
168
|
[0.1.5]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.4...v0.1.5
|
|
136
169
|
[0.1.4]: https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.4
|
package/README.md
CHANGED
|
@@ -27,15 +27,15 @@ 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
|
|
|
38
|
-
Dropped connections are automatically re-established during the active session. Remote commands have a 30-second default timeout. Remote text reads fetch focused ranges instead of downloading complete files, command output is streamed through bounded buffers, and a 32 KB per-turn budget prevents parallel tools from flooding model context. Complete oversized command output is preserved in a permission-restricted temporary file.
|
|
38
|
+
Dropped connections are automatically re-established during the active session. SSH workspace state is also session-aware: `/new` inherits the current workspace, forks and clones retain their source workspace, and `/resume` restores the selected session's recorded endpoint, remote directory, routing mode, and forwards. Remote commands have a 30-second default timeout. Remote text reads fetch focused ranges instead of downloading complete files, command output is streamed through bounded buffers, and a 32 KB per-turn budget prevents parallel tools from flooding model context. Complete oversized command output is preserved in a permission-restricted temporary file.
|
|
39
39
|
|
|
40
40
|
### Hybrid local/remote workflows
|
|
41
41
|
|
|
@@ -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 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 |
|
|
223
|
+
| [0.1.7](CHANGELOG.md#017---2026-08-05) | 2026-08-05 | Session-aware SSH workspace restoration for new, forked, and resumed sessions |
|
|
212
224
|
| [0.1.6](CHANGELOG.md#016---2026-08-05) | 2026-08-05 | Bounded remote reads, streamed command output, and per-turn output budgets |
|
|
213
|
-
| [0.1.5](CHANGELOG.md#015---2026-08-04) | 2026-08-04 | Endpoint-specific server memory and automatic context injection |
|
|
214
|
-
| [0.1.4](CHANGELOG.md#014---2026-07-31) | 2026-07-31 | Endpoint notes and enforced remote command timeouts |
|
|
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,11 +54,11 @@ 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
|
|
|
61
|
-
当前会话中如果 SSH 连接意外断开,插件会尝试自动重连,不需要 Agent 从头建立工作环境。
|
|
61
|
+
当前会话中如果 SSH 连接意外断开,插件会尝试自动重连,不需要 Agent 从头建立工作环境。SSH 工作区状态也会跟随 Pi session:`/new` 继承当前工作区,`/fork` 和 `/clone` 保留来源工作区,`/resume` 恢复目标历史 session 记录的服务器、远程目录、路由模式和端口转发。
|
|
62
62
|
|
|
63
63
|
### 对 Agent 上下文更友好
|
|
64
64
|
|
|
@@ -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
|
|
|
@@ -249,7 +259,7 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
|
|
|
249
259
|
~/.pi/agent/ssh-remote-config.json
|
|
250
260
|
```
|
|
251
261
|
|
|
252
|
-
|
|
262
|
+
全局配置包括:
|
|
253
263
|
|
|
254
264
|
- 已保存的服务器;
|
|
255
265
|
- 当前选中的服务器;
|
|
@@ -257,12 +267,13 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
|
|
|
257
267
|
- 服务器特定记忆;
|
|
258
268
|
- 默认远程目录;
|
|
259
269
|
- 端口转发配置;
|
|
270
|
+
- 私钥文件路径;
|
|
260
271
|
- 命令预览设置;
|
|
261
272
|
- 模型输出预算。
|
|
262
273
|
|
|
263
|
-
|
|
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` 工具名 |
|
|
302
|
+
| [0.1.7](CHANGELOG.md#017---2026-08-05) | 2026-08-05 | 新建、分叉和恢复 session 时自动恢复对应 SSH 工作区 |
|
|
290
303
|
| [0.1.6](CHANGELOG.md#016---2026-08-05) | 2026-08-05 | 有界远端读取、流式命令输出与每轮输出预算 |
|
|
291
|
-
| [0.1.5](CHANGELOG.md#015---2026-08-04) | 2026-08-04 | endpoint 级服务器记忆与自动上下文注入 |
|
|
292
|
-
| [0.1.4](CHANGELOG.md#014---2026-07-31) | 2026-07-31 | 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,22 +33,26 @@ 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>;
|
|
51
|
-
|
|
54
|
+
keyPassphrases: Map<string, string>;
|
|
55
|
+
resume?: { command: string; cwd: string; routeRemoteTools: boolean; forwards?: string[] };
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
interface RemoteEndpointConfig {
|
|
@@ -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;
|
|
@@ -80,6 +86,15 @@ interface ForwardSpec {
|
|
|
80
86
|
remotePort: number;
|
|
81
87
|
}
|
|
82
88
|
|
|
89
|
+
interface SessionRemoteState {
|
|
90
|
+
version: 1;
|
|
91
|
+
connected: boolean;
|
|
92
|
+
command?: string;
|
|
93
|
+
cwd?: string;
|
|
94
|
+
routeRemoteTools?: boolean;
|
|
95
|
+
forwards?: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
83
98
|
const AGENT_DIR = join(process.env.HOME || ".", CONFIG_DIR_NAME, "agent");
|
|
84
99
|
const KNOWN_HOSTS_FILE = join(AGENT_DIR, "ssh-remote-known-hosts.json");
|
|
85
100
|
const REMOTE_CONFIG_FILE = join(AGENT_DIR, "ssh-remote-config.json");
|
|
@@ -95,9 +110,12 @@ const MIN_MODEL_OUTPUT_BYTES = 1024;
|
|
|
95
110
|
const OUTPUT_FOOTER_RESERVE_BYTES = 512;
|
|
96
111
|
const DEFAULT_REMOTE_TIMEOUT_SECONDS = 30;
|
|
97
112
|
const MAX_REMOTE_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
|
|
113
|
+
const MAX_PRIVATE_KEY_BYTES = 1024 * 1024;
|
|
114
|
+
const SESSION_STATE_ENTRY_TYPE = "pi-ssh-remote-state";
|
|
98
115
|
const CACHE_KEY = "__piHpcCredentialCacheV1";
|
|
99
116
|
const cacheHost = globalThis as typeof globalThis & { [CACHE_KEY]?: CredentialCache };
|
|
100
|
-
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>();
|
|
101
119
|
|
|
102
120
|
function shellWords(input: string): string[] {
|
|
103
121
|
const words: string[] = [];
|
|
@@ -122,28 +140,47 @@ function parseSshCommand(command: string): ParsedSsh {
|
|
|
122
140
|
if (args[0] !== "ssh") throw new Error("Command must start with ssh, for example: ssh root@host -p 22");
|
|
123
141
|
let port = 22;
|
|
124
142
|
let username = process.env.USER || "root";
|
|
143
|
+
let identityFile: string | undefined;
|
|
125
144
|
let target: string | undefined;
|
|
126
145
|
for (let i = 1; i < args.length; i++) {
|
|
127
146
|
const arg = args[i]!;
|
|
128
147
|
if (arg === "-p") { port = Number(args[++i]); continue; }
|
|
129
148
|
if (arg.startsWith("-p") && arg.length > 2) { port = Number(arg.slice(2)); continue; }
|
|
130
149
|
if (arg === "-l") { username = args[++i] || username; continue; }
|
|
131
|
-
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`);
|
|
132
162
|
if (!target) target = arg;
|
|
133
163
|
else throw new Error("Unexpected extra argument in SSH command");
|
|
134
164
|
}
|
|
135
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
|
+
}
|
|
136
169
|
const at = target.lastIndexOf("@");
|
|
137
170
|
const host = at >= 0 ? target.slice(at + 1) : target;
|
|
138
171
|
if (at >= 0) username = target.slice(0, at);
|
|
139
172
|
if (!host || !username) throw new Error("Invalid SSH username or host");
|
|
140
|
-
return { host, port, username, label: `${username}@${host}:${port}`, command };
|
|
173
|
+
return { host, port, username, ...(identityFile ? { identityFile } : {}), label: `${username}@${host}:${port}`, command };
|
|
141
174
|
}
|
|
142
175
|
|
|
143
176
|
function cacheId(config: ParsedSsh): string {
|
|
144
177
|
return `${config.username}@${config.host}:${config.port}`;
|
|
145
178
|
}
|
|
146
179
|
|
|
180
|
+
function serverMemoryId(config: ParsedSsh): string {
|
|
181
|
+
return `${config.username}@${config.host}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
147
184
|
function getCachedPassword(config: ParsedSsh): string | undefined {
|
|
148
185
|
return credentialCache.passwords.get(cacheId(config));
|
|
149
186
|
}
|
|
@@ -156,6 +193,60 @@ function deleteCachedPassword(config: ParsedSsh): void {
|
|
|
156
193
|
credentialCache.passwords.delete(cacheId(config));
|
|
157
194
|
}
|
|
158
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
|
+
|
|
159
250
|
function parseForwardSpec(value: string): ForwardSpec {
|
|
160
251
|
const match = value.match(/^(\d+):([^:]+):(\d+)$/);
|
|
161
252
|
if (!match) throw new Error(`Invalid port-forward specification: ${value}; expected LOCAL_PORT:REMOTE_HOST:REMOTE_PORT`);
|
|
@@ -231,11 +322,25 @@ function commandFromEndpointKey(key: string): string | undefined {
|
|
|
231
322
|
|
|
232
323
|
function normalizeRemoteConfig(config: RemoteConfig): RemoteConfig {
|
|
233
324
|
const endpoints = { ...(config.endpoints ?? {}) };
|
|
234
|
-
|
|
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;
|
|
235
336
|
endpoints[key] = {
|
|
236
|
-
...
|
|
237
|
-
...(
|
|
337
|
+
...endpointWithoutMemory,
|
|
338
|
+
...(command ? { sshCommand: command } : {}),
|
|
238
339
|
};
|
|
340
|
+
if (legacyMemory?.trim() && command) {
|
|
341
|
+
try { serverMemories[serverMemoryId(parseSshCommand(command))] ??= legacyMemory.trim(); }
|
|
342
|
+
catch {}
|
|
343
|
+
}
|
|
239
344
|
}
|
|
240
345
|
|
|
241
346
|
let activeEndpoint = config.activeEndpoint;
|
|
@@ -269,6 +374,7 @@ function normalizeRemoteConfig(config: RemoteConfig): RemoteConfig {
|
|
|
269
374
|
return {
|
|
270
375
|
...(activeEndpoint ? { activeEndpoint } : {}),
|
|
271
376
|
...(Object.keys(endpoints).length ? { endpoints } : {}),
|
|
377
|
+
...(Object.keys(serverMemories).length ? { serverMemories } : {}),
|
|
272
378
|
...(displayLines !== undefined ? { displayLines } : {}),
|
|
273
379
|
...(readMaxLines !== undefined ? { readMaxLines } : {}),
|
|
274
380
|
...(readMaxBytes !== undefined ? { readMaxBytes } : {}),
|
|
@@ -307,13 +413,13 @@ function endpointDisplayLabel(endpoint: ParsedSsh, config = loadRemoteConfig()):
|
|
|
307
413
|
}
|
|
308
414
|
|
|
309
415
|
function endpointMemory(endpoint: ParsedSsh, config = loadRemoteConfig()): string | undefined {
|
|
310
|
-
return
|
|
416
|
+
return config.serverMemories?.[serverMemoryId(endpoint)]?.trim() || undefined;
|
|
311
417
|
}
|
|
312
418
|
|
|
313
419
|
function remoteSystemPrompt(systemPrompt: string, localCwd: string, remote: RemoteState): string {
|
|
314
420
|
return systemPrompt.replace(
|
|
315
421
|
`Current working directory: ${localCwd}`,
|
|
316
|
-
`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.`,
|
|
317
423
|
);
|
|
318
424
|
}
|
|
319
425
|
|
|
@@ -336,6 +442,24 @@ function saveEndpointConfig(command: string, updates: RemoteEndpointConfig, make
|
|
|
336
442
|
});
|
|
337
443
|
}
|
|
338
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
|
+
|
|
339
463
|
function loadKnownHosts(): Record<string, string> {
|
|
340
464
|
try { return JSON.parse(readFileSync(KNOWN_HOSTS_FILE, "utf8")); }
|
|
341
465
|
catch { return {}; }
|
|
@@ -508,15 +632,16 @@ function probeFingerprint(config: ParsedSsh): Promise<string> {
|
|
|
508
632
|
});
|
|
509
633
|
}
|
|
510
634
|
|
|
511
|
-
|
|
635
|
+
type SshAuthentication = Partial<Pick<ConnectConfig, "password" | "privateKey" | "passphrase" | "agent">>;
|
|
636
|
+
|
|
637
|
+
function connect(config: ParsedSsh, authentication: SshAuthentication, fingerprint: string): Promise<SshClient> {
|
|
512
638
|
return new Promise((resolve, reject) => {
|
|
513
639
|
const client = new Client();
|
|
514
640
|
const options: ConnectConfig = {
|
|
515
641
|
host: config.host,
|
|
516
642
|
port: config.port,
|
|
517
643
|
username: config.username,
|
|
518
|
-
...
|
|
519
|
-
...(process.env.SSH_AUTH_SOCK ? { agent: process.env.SSH_AUTH_SOCK } : {}),
|
|
644
|
+
...authentication,
|
|
520
645
|
readyTimeout: 12000,
|
|
521
646
|
keepaliveInterval: 15000,
|
|
522
647
|
keepaliveCountMax: 3,
|
|
@@ -530,7 +655,7 @@ function connect(config: ParsedSsh, password: string | undefined, fingerprint: s
|
|
|
530
655
|
}
|
|
531
656
|
|
|
532
657
|
function execRemote(
|
|
533
|
-
client:
|
|
658
|
+
client: SshClient,
|
|
534
659
|
command: string,
|
|
535
660
|
allowFailure = false,
|
|
536
661
|
timeoutSeconds = DEFAULT_REMOTE_TIMEOUT_SECONDS,
|
|
@@ -564,7 +689,7 @@ function execRemote(
|
|
|
564
689
|
}
|
|
565
690
|
|
|
566
691
|
function execRemoteLimited(
|
|
567
|
-
client:
|
|
692
|
+
client: SshClient,
|
|
568
693
|
command: string,
|
|
569
694
|
timeoutSeconds: number,
|
|
570
695
|
maxLines: number,
|
|
@@ -610,11 +735,11 @@ function execRemoteLimited(
|
|
|
610
735
|
});
|
|
611
736
|
}
|
|
612
737
|
|
|
613
|
-
function getSftp(client:
|
|
738
|
+
function getSftp(client: SshClient): Promise<SFTPWrapper> {
|
|
614
739
|
return new Promise((resolve, reject) => client.sftp((error, sftp) => error ? reject(error) : resolve(sftp)));
|
|
615
740
|
}
|
|
616
741
|
|
|
617
|
-
async function withSftp<T>(client:
|
|
742
|
+
async function withSftp<T>(client: SshClient, operation: (sftp: SFTPWrapper) => Promise<T>): Promise<T> {
|
|
618
743
|
const sftp = await getSftp(client);
|
|
619
744
|
try { return await operation(sftp); }
|
|
620
745
|
finally { sftp.end(); }
|
|
@@ -625,10 +750,10 @@ function isReconnectable(error: unknown): boolean {
|
|
|
625
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);
|
|
626
751
|
}
|
|
627
752
|
|
|
628
|
-
class
|
|
753
|
+
class SecretInput implements Component, Focusable {
|
|
629
754
|
focused = false;
|
|
630
755
|
private value = "";
|
|
631
|
-
constructor(private done: (value: string | null) => void, private renderNow: () => void) {}
|
|
756
|
+
constructor(private label: string, private done: (value: string | null) => void, private renderNow: () => void) {}
|
|
632
757
|
handleInput(data: string): void {
|
|
633
758
|
if (matchesKey(data, Key.enter)) return this.done(this.value);
|
|
634
759
|
if (matchesKey(data, Key.escape)) return this.done(null);
|
|
@@ -641,15 +766,19 @@ class PasswordInput implements Component, Focusable {
|
|
|
641
766
|
this.renderNow();
|
|
642
767
|
}
|
|
643
768
|
render(width: number): string[] {
|
|
644
|
-
return [truncateToWidth(
|
|
769
|
+
return [truncateToWidth(`${this.label}: ${"•".repeat([...this.value].length)}${this.focused ? CURSOR_MARKER : ""}\x1b[7m \x1b[27m`, width, "")];
|
|
645
770
|
}
|
|
646
771
|
invalidate(): void {}
|
|
647
772
|
}
|
|
648
773
|
|
|
649
|
-
async function
|
|
650
|
-
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;
|
|
651
776
|
return ctx.ui.custom<string | null>((tui: any, _theme: any, _keys: any, done: (value: string | null) => void) =>
|
|
652
|
-
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");
|
|
653
782
|
}
|
|
654
783
|
|
|
655
784
|
export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
@@ -659,6 +788,9 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
659
788
|
let currentCtx: any;
|
|
660
789
|
let reconnectPromise: Promise<RemoteState> | null = null;
|
|
661
790
|
const forwardServers = new Map<number, Server>();
|
|
791
|
+
const forwardSpecs = new Map<number, ForwardSpec>();
|
|
792
|
+
let sessionReady = false;
|
|
793
|
+
let restoringSessionState = false;
|
|
662
794
|
let lastConnectionError: string | undefined;
|
|
663
795
|
let lastCommand = credentialCache.resume?.command ?? activeSshCommand() ?? "";
|
|
664
796
|
let turnOutputBytes = 0;
|
|
@@ -669,6 +801,33 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
669
801
|
const configuredForwards = (command: string): string[] =>
|
|
670
802
|
endpointConfig(loadRemoteConfig(), command).forwards ?? [];
|
|
671
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
|
+
|
|
672
831
|
const mapPath = (path: string): string => {
|
|
673
832
|
if (!remote) return path;
|
|
674
833
|
if (path === localCwd) return remote.cwd;
|
|
@@ -684,6 +843,42 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
684
843
|
return posix.join(remote.cwd, normalized);
|
|
685
844
|
};
|
|
686
845
|
|
|
846
|
+
const serializeForward = (spec: ForwardSpec): string => `${spec.localPort}:${spec.remoteHost}:${spec.remotePort}`;
|
|
847
|
+
|
|
848
|
+
const currentSessionRemoteState = (): SessionRemoteState => remote ? {
|
|
849
|
+
version: 1,
|
|
850
|
+
connected: true,
|
|
851
|
+
command: remote.command,
|
|
852
|
+
cwd: remote.cwd,
|
|
853
|
+
routeRemoteTools,
|
|
854
|
+
forwards: [...forwardSpecs.values()].map(serializeForward),
|
|
855
|
+
} : { version: 1, connected: false };
|
|
856
|
+
|
|
857
|
+
const persistSessionRemoteState = (): void => {
|
|
858
|
+
if (!sessionReady || restoringSessionState) return;
|
|
859
|
+
pi.appendEntry(SESSION_STATE_ENTRY_TYPE, currentSessionRemoteState());
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
const loadSessionRemoteState = (ctx: any): SessionRemoteState | undefined => {
|
|
863
|
+
const branch = ctx.sessionManager.getBranch();
|
|
864
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
865
|
+
const entry = branch[index] as any;
|
|
866
|
+
if (entry.type !== "custom" || entry.customType !== SESSION_STATE_ENTRY_TYPE) continue;
|
|
867
|
+
const data = entry.data as Partial<SessionRemoteState> | undefined;
|
|
868
|
+
if (!data || data.version !== 1 || typeof data.connected !== "boolean") return undefined;
|
|
869
|
+
if (data.connected && (typeof data.command !== "string" || typeof data.cwd !== "string")) return undefined;
|
|
870
|
+
return {
|
|
871
|
+
version: 1,
|
|
872
|
+
connected: data.connected,
|
|
873
|
+
...(data.command ? { command: data.command } : {}),
|
|
874
|
+
...(data.cwd ? { cwd: data.cwd } : {}),
|
|
875
|
+
...(typeof data.routeRemoteTools === "boolean" ? { routeRemoteTools: data.routeRemoteTools } : {}),
|
|
876
|
+
...(Array.isArray(data.forwards) ? { forwards: data.forwards.filter((value): value is string => typeof value === "string") } : {}),
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
return undefined;
|
|
880
|
+
};
|
|
881
|
+
|
|
687
882
|
const limitRemoteToolResult = (result: any, kind: "read" | "exec", startLine = 1, requestedMaxLines?: number) => {
|
|
688
883
|
const limits = configuredOutputLimits();
|
|
689
884
|
const configuredMaxBytes = kind === "read" ? limits.readMaxBytes : limits.execMaxBytes;
|
|
@@ -753,11 +948,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
753
948
|
});
|
|
754
949
|
};
|
|
755
950
|
|
|
756
|
-
const establish = async (parsed: ParsedSsh,
|
|
951
|
+
const establish = async (parsed: ParsedSsh, authentication: SshAuthentication, cwd: string): Promise<RemoteState> => {
|
|
757
952
|
const key = `${parsed.host}:${parsed.port}`;
|
|
758
953
|
const fingerprint = loadKnownHosts()[key];
|
|
759
954
|
if (!fingerprint) throw new Error(`Host ${key} is not trusted; connect interactively with /remote first`);
|
|
760
|
-
const client = await connect(parsed,
|
|
955
|
+
const client = await connect(parsed, authentication, fingerprint);
|
|
761
956
|
try {
|
|
762
957
|
const cdCommand = cwd === FALLBACK_REMOTE_CWD ? "cd -- ~" : `cd -- ${quote(cwd)}`;
|
|
763
958
|
const resolved = (await execRemote(client, `${cdCommand} && pwd -P`)).toString().trim();
|
|
@@ -772,15 +967,20 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
772
967
|
|
|
773
968
|
async function reconnectRemote(): Promise<RemoteState> {
|
|
774
969
|
if (reconnectPromise) return reconnectPromise;
|
|
970
|
+
const resumeRouting = remote ? routeRemoteTools : (credentialCache.resume?.routeRemoteTools ?? true);
|
|
775
971
|
const source = remote ?? (credentialCache.resume ? { ...parseSshCommand(credentialCache.resume.command), cwd: credentialCache.resume.cwd } : null);
|
|
776
972
|
if (!source) throw new Error("No SSH remote connection is available to reconnect");
|
|
777
973
|
const parsed = parseSshCommand(source.command);
|
|
778
974
|
const password = getCachedPassword(parsed);
|
|
779
975
|
reconnectPromise = (async () => {
|
|
976
|
+
const authentication = parsed.identityFile
|
|
977
|
+
? await privateKeyAuthentication(parsed)
|
|
978
|
+
: standardAuthentication(password);
|
|
780
979
|
const oldClient = remote?.client;
|
|
781
|
-
const next = await establish(parsed,
|
|
980
|
+
const next = await establish(parsed, authentication, source.cwd);
|
|
782
981
|
remote = next;
|
|
783
|
-
|
|
982
|
+
routeRemoteTools = resumeRouting;
|
|
983
|
+
credentialCache.resume = { command: parsed.command, cwd: next.cwd, routeRemoteTools, forwards: credentialCache.resume?.forwards };
|
|
784
984
|
oldClient?.end();
|
|
785
985
|
if (currentCtx) {
|
|
786
986
|
status(currentCtx);
|
|
@@ -791,7 +991,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
791
991
|
return reconnectPromise;
|
|
792
992
|
}
|
|
793
993
|
|
|
794
|
-
const withReconnect = async <T>(operation: (client:
|
|
994
|
+
const withReconnect = async <T>(operation: (client: SshClient) => Promise<T>): Promise<T> => {
|
|
795
995
|
if (!remote) throw new Error("SSH remote is not connected");
|
|
796
996
|
try { return await operation(remote.client); }
|
|
797
997
|
catch (error) {
|
|
@@ -810,9 +1010,10 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
810
1010
|
`cd -- ${quote(remote!.cwd)} && ${targetCommand} && pwd -P`,
|
|
811
1011
|
))).toString().trim();
|
|
812
1012
|
remote.cwd = resolved;
|
|
813
|
-
credentialCache.resume = { command: remote.command, cwd: resolved };
|
|
1013
|
+
credentialCache.resume = { command: remote.command, cwd: resolved, routeRemoteTools, forwards: [...forwardSpecs.values()].map(serializeForward) };
|
|
814
1014
|
saveEndpointConfig(remote.command, { remoteCwd: resolved }, true);
|
|
815
1015
|
status(ctx);
|
|
1016
|
+
persistSessionRemoteState();
|
|
816
1017
|
return resolved;
|
|
817
1018
|
};
|
|
818
1019
|
|
|
@@ -856,33 +1057,38 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
856
1057
|
let password = getCachedPassword(parsed);
|
|
857
1058
|
ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("warning", `SSH remote connecting ${endpointDisplayLabel(parsed)}…`));
|
|
858
1059
|
try {
|
|
1060
|
+
let authentication = parsed.identityFile
|
|
1061
|
+
? await privateKeyAuthentication(parsed, ctx)
|
|
1062
|
+
: standardAuthentication(password);
|
|
859
1063
|
let next: RemoteState;
|
|
860
1064
|
try {
|
|
861
|
-
next = await establish(parsed,
|
|
1065
|
+
next = await establish(parsed, authentication, cwd ?? configuredCwd(command));
|
|
862
1066
|
} catch (error) {
|
|
863
|
-
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;
|
|
864
1068
|
password = await askPassword(ctx) ?? undefined;
|
|
865
1069
|
if (!password) {
|
|
866
1070
|
lastConnectionError = "No SSH password was provided and SSH agent authentication failed";
|
|
867
1071
|
status(ctx);
|
|
868
1072
|
return null;
|
|
869
1073
|
}
|
|
870
|
-
|
|
1074
|
+
authentication = standardAuthentication(password);
|
|
1075
|
+
next = await establish(parsed, authentication, cwd ?? configuredCwd(command));
|
|
871
1076
|
}
|
|
872
1077
|
const previous = remote?.client;
|
|
873
1078
|
remote = next;
|
|
874
1079
|
routeRemoteTools = true;
|
|
875
1080
|
previous?.end();
|
|
876
|
-
if (password) setCachedPassword(parsed, password);
|
|
877
|
-
credentialCache.resume = { command, cwd: next.cwd };
|
|
1081
|
+
if (!parsed.identityFile && password) setCachedPassword(parsed, password);
|
|
1082
|
+
credentialCache.resume = { command, cwd: next.cwd, routeRemoteTools, forwards: [] };
|
|
878
1083
|
lastCommand = command;
|
|
879
1084
|
lastConnectionError = undefined;
|
|
880
1085
|
saveEndpointConfig(command, { remoteCwd: next.cwd }, true);
|
|
881
1086
|
status(ctx);
|
|
1087
|
+
persistSessionRemoteState();
|
|
882
1088
|
ctx.ui.notify(`SSH remote connected: ${endpointDisplayLabel(next)}:${next.cwd}`, "info");
|
|
883
1089
|
return next;
|
|
884
1090
|
} catch (error) {
|
|
885
|
-
deleteCachedPassword(parsed);
|
|
1091
|
+
if (!parsed.identityFile) deleteCachedPassword(parsed);
|
|
886
1092
|
remote = null;
|
|
887
1093
|
lastConnectionError = (error as Error).message;
|
|
888
1094
|
status(ctx);
|
|
@@ -895,7 +1101,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
895
1101
|
if (remote) return remote;
|
|
896
1102
|
if (credentialCache.resume) return reconnectRemote();
|
|
897
1103
|
const command = lastCommand || activeSshCommand();
|
|
898
|
-
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]");
|
|
899
1105
|
const state = await connectInteractive(command, ctx, configuredCwd(command));
|
|
900
1106
|
if (!state) throw new Error("SSH remote connection was cancelled or failed");
|
|
901
1107
|
return state;
|
|
@@ -922,33 +1128,54 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
922
1128
|
});
|
|
923
1129
|
});
|
|
924
1130
|
forwardServers.set(spec.localPort, server);
|
|
1131
|
+
forwardSpecs.set(spec.localPort, spec);
|
|
925
1132
|
};
|
|
926
1133
|
|
|
927
1134
|
const stopForwards = async (): Promise<void> => {
|
|
928
1135
|
const servers = [...forwardServers.values()];
|
|
929
1136
|
forwardServers.clear();
|
|
1137
|
+
forwardSpecs.clear();
|
|
930
1138
|
await Promise.all(servers.map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
|
931
1139
|
};
|
|
932
1140
|
|
|
933
|
-
const
|
|
1141
|
+
const restoreSessionRemoteState = async (saved: SessionRemoteState, ctx: any): Promise<void> => {
|
|
1142
|
+
if (!saved.connected) {
|
|
1143
|
+
credentialCache.resume = undefined;
|
|
1144
|
+
status(ctx);
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const command = saved.command!;
|
|
1148
|
+
const next = await connectInteractive(command, ctx, saved.cwd);
|
|
1149
|
+
if (!next) throw new Error(lastConnectionError || "SSH remote session restore was cancelled or failed");
|
|
1150
|
+
routeRemoteTools = saved.routeRemoteTools ?? true;
|
|
1151
|
+
const specs = (saved.forwards ?? []).map(parseForwardSpec);
|
|
1152
|
+
for (const spec of specs) await startForward(spec);
|
|
1153
|
+
credentialCache.resume = { command, cwd: next.cwd, routeRemoteTools, forwards: [...forwardSpecs.values()].map(serializeForward) };
|
|
1154
|
+
status(ctx);
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
const disconnect = (ctx: any, forgetCredentials = false) => {
|
|
934
1158
|
const previous = remote;
|
|
935
1159
|
remote = null;
|
|
936
1160
|
routeRemoteTools = false;
|
|
937
1161
|
reconnectPromise = null;
|
|
938
1162
|
credentialCache.resume = undefined;
|
|
939
1163
|
void stopForwards();
|
|
940
|
-
if (
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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);
|
|
947
1173
|
}
|
|
948
1174
|
}
|
|
949
1175
|
previous?.client.end();
|
|
950
1176
|
status(ctx);
|
|
951
|
-
|
|
1177
|
+
persistSessionRemoteState();
|
|
1178
|
+
ctx.ui.notify(forgetCredentials ? "SSH remote disconnected and cached credentials cleared" : "SSH remote mode disabled (credentials remain cached in memory only)", "info");
|
|
952
1179
|
};
|
|
953
1180
|
|
|
954
1181
|
const detectRemoteMimeType = async (path: string): Promise<string | undefined> => {
|
|
@@ -1065,22 +1292,22 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1065
1292
|
});
|
|
1066
1293
|
|
|
1067
1294
|
pi.registerTool({
|
|
1068
|
-
name: "
|
|
1069
|
-
label: "
|
|
1070
|
-
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.",
|
|
1071
1298
|
promptSnippet: "Control the configured remote SSH connection, endpoint note and memory, working directory, and local port forwarding",
|
|
1072
1299
|
promptGuidelines: [
|
|
1073
|
-
"Use
|
|
1074
|
-
"Use
|
|
1075
|
-
`Always set timeout for
|
|
1076
|
-
"Keep
|
|
1077
|
-
"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.",
|
|
1078
1305
|
],
|
|
1079
1306
|
parameters: Type.Object({
|
|
1080
1307
|
action: StringEnum(["connect", "reconnect", "status", "disconnect", "forget", "forward", "unforward", "exec", "chdir", "note", "memory"] as const),
|
|
1081
|
-
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" })),
|
|
1082
1309
|
note: Type.Optional(Type.String({ description: "Endpoint note for the note action; omit or use an empty string to clear it" })),
|
|
1083
|
-
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" })),
|
|
1084
1311
|
cwd: Type.Optional(Type.String({ description: "Remote working directory; required for chdir, and a one-command override for exec" })),
|
|
1085
1312
|
forwards: Type.Optional(Type.String({ description: "Space-separated LOCAL_PORT:REMOTE_HOST:REMOTE_PORT mappings; defaults to ssh-remote-config.json" })),
|
|
1086
1313
|
remoteCommand: Type.Optional(Type.String({ description: "Remote shell command for the exec action" })),
|
|
@@ -1095,7 +1322,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1095
1322
|
}
|
|
1096
1323
|
if (params.action === "disconnect" || params.action === "forget") {
|
|
1097
1324
|
disconnect(ctx, params.action === "forget");
|
|
1098
|
-
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 } };
|
|
1099
1326
|
}
|
|
1100
1327
|
if (params.action === "reconnect") {
|
|
1101
1328
|
if (!remote && !credentialCache.resume) throw new Error("No SSH remote connection is available to reconnect");
|
|
@@ -1104,6 +1331,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1104
1331
|
}
|
|
1105
1332
|
if (params.action === "unforward") {
|
|
1106
1333
|
await stopForwards();
|
|
1334
|
+
persistSessionRemoteState();
|
|
1107
1335
|
return { content: [{ type: "text", text: "Closed all extension-managed SSH port forwards." }], details: { forwardedPorts: [] } };
|
|
1108
1336
|
}
|
|
1109
1337
|
if (params.action === "forward") {
|
|
@@ -1113,7 +1341,9 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1113
1341
|
const specs = values.map(parseForwardSpec);
|
|
1114
1342
|
for (const spec of specs) await startForward(spec);
|
|
1115
1343
|
routeRemoteTools = false;
|
|
1344
|
+
credentialCache.resume = { command: state.command, cwd: state.cwd, routeRemoteTools, forwards: [...forwardSpecs.values()].map(serializeForward) };
|
|
1116
1345
|
if (currentCtx) status(currentCtx);
|
|
1346
|
+
persistSessionRemoteState();
|
|
1117
1347
|
const ports = specs.map((spec) => spec.localPort);
|
|
1118
1348
|
return { content: [{ type: "text", text: `Forwarded local ports: ${ports.join(", ")}; tools remain local.` }], details: { toolRouting: "local", forwardedPorts: ports } };
|
|
1119
1349
|
}
|
|
@@ -1137,10 +1367,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1137
1367
|
};
|
|
1138
1368
|
}
|
|
1139
1369
|
const memory = params.memory?.trim() || undefined;
|
|
1140
|
-
|
|
1370
|
+
const server = serverMemoryId(parseSshCommand(command));
|
|
1371
|
+
saveServerMemory(command, memory);
|
|
1141
1372
|
return {
|
|
1142
|
-
content: [{ type: "text", text: memory ? `SSH remote server memory updated (${
|
|
1143
|
-
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 },
|
|
1144
1375
|
};
|
|
1145
1376
|
}
|
|
1146
1377
|
if (params.action === "exec") {
|
|
@@ -1187,7 +1418,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1187
1418
|
});
|
|
1188
1419
|
|
|
1189
1420
|
pi.registerCommand("remote", {
|
|
1190
|
-
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",
|
|
1191
1422
|
handler: async (args, ctx) => {
|
|
1192
1423
|
const input = args.trim().replace(/^\/?remote(?:\s+|$)/i, "").trim();
|
|
1193
1424
|
const action = input.toLowerCase();
|
|
@@ -1195,7 +1426,9 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1195
1426
|
const config = loadRemoteConfig();
|
|
1196
1427
|
const rows = Object.entries(config.endpoints ?? {}).map(([key, endpoint]) => {
|
|
1197
1428
|
const active = key === config.activeEndpoint ? "*" : " ";
|
|
1198
|
-
|
|
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"}`;
|
|
1199
1432
|
});
|
|
1200
1433
|
const limits = configuredOutputLimits(config);
|
|
1201
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");
|
|
@@ -1231,6 +1464,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1231
1464
|
previous.client.end();
|
|
1232
1465
|
await stopForwards();
|
|
1233
1466
|
status(ctx);
|
|
1467
|
+
persistSessionRemoteState();
|
|
1234
1468
|
}
|
|
1235
1469
|
saveRemoteConfig({ ...config, activeEndpoint: key });
|
|
1236
1470
|
lastCommand = command;
|
|
@@ -1254,8 +1488,9 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1254
1488
|
const command = lastCommand || activeSshCommand();
|
|
1255
1489
|
if (!command) { ctx.ui.notify("Configure an SSH endpoint first", "error"); return; }
|
|
1256
1490
|
const memory = value.toLowerCase() === "--clear" ? undefined : value;
|
|
1257
|
-
|
|
1258
|
-
|
|
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");
|
|
1259
1494
|
return;
|
|
1260
1495
|
}
|
|
1261
1496
|
if (/^config\s+display-lines\s+/i.test(input)) {
|
|
@@ -1309,13 +1544,16 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1309
1544
|
const specs = values.map(parseForwardSpec);
|
|
1310
1545
|
for (const spec of specs) await startForward(spec);
|
|
1311
1546
|
routeRemoteTools = false;
|
|
1547
|
+
credentialCache.resume = { command: state.command, cwd: state.cwd, routeRemoteTools, forwards: [...forwardSpecs.values()].map(serializeForward) };
|
|
1312
1548
|
status(ctx);
|
|
1549
|
+
persistSessionRemoteState();
|
|
1313
1550
|
ctx.ui.notify(`SSH remote port forwarding started; tools remain local in ${localCwd}: ${specs.map((spec) => `127.0.0.1:${spec.localPort}`).join(", ")}`, "info");
|
|
1314
1551
|
} catch (error) { ctx.ui.notify(`SSH remote port forwarding failed: ${(error as Error).message}`, "error"); }
|
|
1315
1552
|
return;
|
|
1316
1553
|
}
|
|
1317
1554
|
if (action === "unforward") {
|
|
1318
1555
|
await stopForwards();
|
|
1556
|
+
persistSessionRemoteState();
|
|
1319
1557
|
ctx.ui.notify("Closed all extension-managed SSH remote port forwards", "info");
|
|
1320
1558
|
return;
|
|
1321
1559
|
}
|
|
@@ -1378,19 +1616,50 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1378
1616
|
pi.on("turn_start", () => { turnOutputBytes = 0; });
|
|
1379
1617
|
pi.on("session_start", async (event, ctx) => {
|
|
1380
1618
|
currentCtx = ctx;
|
|
1619
|
+
sessionReady = true;
|
|
1381
1620
|
status(ctx);
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1621
|
+
const saved = loadSessionRemoteState(ctx);
|
|
1622
|
+
const canInheritCurrent = event.reason === "reload" || event.reason === "new" || event.reason === "fork";
|
|
1623
|
+
const inherited = !saved && canInheritCurrent && credentialCache.resume ? credentialCache.resume : undefined;
|
|
1624
|
+
const target: SessionRemoteState | undefined = saved ?? (inherited ? {
|
|
1625
|
+
version: 1,
|
|
1626
|
+
connected: true,
|
|
1627
|
+
command: inherited.command,
|
|
1628
|
+
cwd: inherited.cwd,
|
|
1629
|
+
routeRemoteTools: inherited.routeRemoteTools,
|
|
1630
|
+
forwards: inherited.forwards ?? [],
|
|
1631
|
+
} : undefined);
|
|
1632
|
+
if (!target) {
|
|
1633
|
+
credentialCache.resume = undefined;
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
restoringSessionState = true;
|
|
1637
|
+
try {
|
|
1638
|
+
await restoreSessionRemoteState(target, ctx);
|
|
1639
|
+
} catch (error) {
|
|
1640
|
+
ctx.ui.notify(`SSH remote session restore failed: ${(error as Error).message}`, "error");
|
|
1641
|
+
} finally {
|
|
1642
|
+
restoringSessionState = false;
|
|
1385
1643
|
}
|
|
1644
|
+
if (inherited && remote) persistSessionRemoteState();
|
|
1386
1645
|
});
|
|
1387
|
-
pi.on("session_shutdown", (event) => {
|
|
1646
|
+
pi.on("session_shutdown", async (event) => {
|
|
1647
|
+
sessionReady = false;
|
|
1388
1648
|
const previous = remote;
|
|
1649
|
+
const preserveConnection = event.reason === "reload" || event.reason === "new" || event.reason === "fork";
|
|
1650
|
+
if (previous && preserveConnection) {
|
|
1651
|
+
credentialCache.resume = {
|
|
1652
|
+
command: previous.command,
|
|
1653
|
+
cwd: previous.cwd,
|
|
1654
|
+
routeRemoteTools,
|
|
1655
|
+
forwards: [...forwardSpecs.values()].map(serializeForward),
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1389
1658
|
remote = null;
|
|
1390
1659
|
routeRemoteTools = false;
|
|
1391
|
-
|
|
1660
|
+
await stopForwards();
|
|
1392
1661
|
previous?.client.end();
|
|
1393
|
-
if (
|
|
1662
|
+
if (!preserveConnection) credentialCache.resume = undefined;
|
|
1394
1663
|
});
|
|
1395
1664
|
pi.on("user_bash", async (event, ctx) => {
|
|
1396
1665
|
if (!remote || !routeRemoteTools) return undefined;
|