pi-ssh-remote 0.1.11 → 0.1.13
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 +33 -1
- package/LICENSE +0 -0
- package/README.md +30 -28
- package/README.zh-CN.md +31 -39
- package/index.ts +42 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,36 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
|
|
|
18
18
|
|
|
19
19
|
- None.
|
|
20
20
|
|
|
21
|
+
## [0.1.13] - 2026-09-18
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- None.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Increased the SSH handshake timeout to 30 seconds for slower remote connections.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Stored SSH config, known hosts, and server memory in Pi's agent directory (`~/.pi/agent`, or `PI_CODING_AGENT_DIR`) instead of falling back to the current working directory when `HOME` is unset, which mainly affected Windows.
|
|
34
|
+
- Expanded `ssh -i ~/...` identity paths with the user home directory from `os.homedir()`, so private keys resolve on Windows.
|
|
35
|
+
- Fell back to the remote login directory with a warning when a saved remote working directory is unavailable, instead of failing the SSH connection.
|
|
36
|
+
|
|
37
|
+
## [0.1.12] - 2026-08-25
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- None.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- Simplified the server-memory documentation to focus on adding, listing, updating, and deleting entries without exposing implementation details.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- Prevented follow-up SSH client errors during a connection loss before handshake from becoming an uncaught exception that exits Pi.
|
|
50
|
+
|
|
21
51
|
## [0.1.11] - 2026-08-16
|
|
22
52
|
|
|
23
53
|
### Added
|
|
@@ -206,7 +236,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
|
|
|
206
236
|
|
|
207
237
|
- None.
|
|
208
238
|
|
|
209
|
-
[Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.
|
|
239
|
+
[Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.13...HEAD
|
|
240
|
+
[0.1.13]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.12...v0.1.13
|
|
241
|
+
[0.1.12]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.11...v0.1.12
|
|
210
242
|
[0.1.11]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.10...v0.1.11
|
|
211
243
|
[0.1.10]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.9...v0.1.10
|
|
212
244
|
[0.1.9]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.8...v0.1.9
|
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ The extension exposes `remote` for the agent and `/remote` for the user. You can
|
|
|
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, and port-forward configuration locally.
|
|
34
|
+
Each `user@host:port` endpoint remembers its remote working directory, note, and port-forward configuration locally. 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.
|
|
35
35
|
|
|
36
36
|
### Resilient and bounded by default
|
|
37
37
|
|
|
@@ -67,8 +67,6 @@ Requires Node.js 20+. The remote server must provide Bash, SFTP, and GNU `timeou
|
|
|
67
67
|
/remote cd /srv/project
|
|
68
68
|
/remote config note H100 training server
|
|
69
69
|
/remote status
|
|
70
|
-
|
|
71
|
-
You: Add a server-memory entry saying to use /opt/conda/bin/python and never stop shared jobs.
|
|
72
70
|
```
|
|
73
71
|
|
|
74
72
|
From this point, normal Pi operations target `/srv/project` on the remote server. The footer makes that routing explicit:
|
|
@@ -83,6 +81,29 @@ Return to local tools with:
|
|
|
83
81
|
/remote off
|
|
84
82
|
```
|
|
85
83
|
|
|
84
|
+
### Server memory
|
|
85
|
+
|
|
86
|
+
Tell Pi what to remember for the current server:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
Remember for this server: use /opt/conda/bin/python and never stop shared jobs.
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
List saved entries and their IDs:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
/remote memory
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Use an ID to update or delete an entry:
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
Update memory deploy-command to use /opt/deploy/v2/run.sh.
|
|
102
|
+
Delete memory obsolete-proxy-rule.
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Pi automatically applies saved memory when you work on that server. Deletion only happens when you explicitly request it.
|
|
106
|
+
|
|
86
107
|
### Private key authentication
|
|
87
108
|
|
|
88
109
|
Pass an explicit local private key with `-i`:
|
|
@@ -132,7 +153,7 @@ The default foreground timeout prevents an accidental long-running command from
|
|
|
132
153
|
/remote
|
|
133
154
|
```
|
|
134
155
|
|
|
135
|
-
Endpoint notes and working directories survive Pi restarts
|
|
156
|
+
Endpoint notes and working directories survive Pi restarts and remain separate for each saved endpoint.
|
|
136
157
|
|
|
137
158
|
### 4. Expose a remote service while editing locally
|
|
138
159
|
|
|
@@ -165,25 +186,6 @@ Forward the remote service on port 8000 to localhost:8000, but keep my coding
|
|
|
165
186
|
tools on the local repository.
|
|
166
187
|
```
|
|
167
188
|
|
|
168
|
-
```text
|
|
169
|
-
Add a server-memory entry that says deployments must use /opt/deploy/run.sh.
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
List the current server's entries and IDs before referring to one:
|
|
173
|
-
|
|
174
|
-
```text
|
|
175
|
-
/remote memory
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
Then update or delete by the displayed ID:
|
|
179
|
-
|
|
180
|
-
```text
|
|
181
|
-
Update the entry with ID deploy-command to use /opt/deploy/v2/run.sh.
|
|
182
|
-
Delete the server-memory entry with ID obsolete-proxy-rule.
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
These requests are handled through the agent-facing `remote` tool; slash commands are optional. The `memory` action locates and inspects the matching JSON file, while Pi's normal `read`, `edit`, and `write` tools perform entry-level add, query, update, and delete operations. The injected instructions make deletion deliberately strict: Pi must first read the file, identify the exact entry ID, and delete only that object—and only after the user explicitly asks to delete, remove, or forget memory. Ambiguous targets require clarification, update requests do not imply deletion, and deleting all entries requires an explicit request to delete all server memory.
|
|
186
|
-
|
|
187
189
|
## Command reference
|
|
188
190
|
|
|
189
191
|
| Command | Purpose |
|
|
@@ -194,7 +196,7 @@ These requests are handled through the agent-facing `remote` tool; slash command
|
|
|
194
196
|
| `/remote use USER@HOST:PORT` | Select a saved endpoint |
|
|
195
197
|
| `/remote config note TEXT` | Persist a note for the selected endpoint |
|
|
196
198
|
| `/remote config note --clear` | Clear its note |
|
|
197
|
-
| `/remote memory` | List the current server's memory entries
|
|
199
|
+
| `/remote memory` | List the current server's memory entries and IDs |
|
|
198
200
|
| `/remote config cwd PATH` | Persist its default remote working directory |
|
|
199
201
|
| `/remote cd PATH` | Change the connected remote cwd and persist it |
|
|
200
202
|
| `/remote config forward MAPPING...` | Persist port forwards such as `7860:127.0.0.1:7860` |
|
|
@@ -222,7 +224,7 @@ Endpoint configuration is stored locally in:
|
|
|
222
224
|
~/.pi/agent/ssh-remote-config.json
|
|
223
225
|
```
|
|
224
226
|
|
|
225
|
-
Saved global values include endpoints, active endpoint, notes, remote working directories, forwards, identity file paths, preview settings, and model-output budgets.
|
|
227
|
+
Saved global values include endpoints, active endpoint, notes, 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. 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.
|
|
226
228
|
|
|
227
229
|
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.
|
|
228
230
|
|
|
@@ -232,13 +234,13 @@ The extension currently supports direct SSH commands with `-p`, `-l`, and `-i`.
|
|
|
232
234
|
|
|
233
235
|
## Releases
|
|
234
236
|
|
|
235
|
-
Latest release: [v0.1.
|
|
237
|
+
Latest release: [v0.1.13](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.13)
|
|
236
238
|
|
|
237
239
|
| Version | Date | Highlights |
|
|
238
240
|
|---|---|---|
|
|
241
|
+
| [0.1.13](CHANGELOG.md#0113---2026-09-18) | 2026-09-18 | Graceful working-directory fallback, longer SSH handshakes, and Windows path fixes |
|
|
242
|
+
| [0.1.12](CHANGELOG.md#0112---2026-08-25) | 2026-08-25 | Safe handling of repeated SSH errors when a connection is lost during handshake |
|
|
239
243
|
| [0.1.11](CHANGELOG.md#0111---2026-08-16) | 2026-08-16 | JSON memory entries, prompt-guided CRUD, and `/remote memory` listing |
|
|
240
|
-
| [0.1.10](CHANGELOG.md#0110---2026-08-16) | 2026-08-16 | Safe inspect-by-default and append-only agent updates for server memory |
|
|
241
|
-
| [0.1.9](CHANGELOG.md#019---2026-08-12) | 2026-08-12 | Stable deduplicated server-memory context and shorter footer labels |
|
|
242
244
|
|
|
243
245
|
See [CHANGELOG.md](CHANGELOG.md) for the complete release history, including additions, behavior changes, and bug fixes.
|
|
244
246
|
|
package/README.zh-CN.md
CHANGED
|
@@ -54,7 +54,7 @@ H100 训练机 (root@gpu-box.example.com:2202):/srv/project
|
|
|
54
54
|
- 服务器特定记忆;
|
|
55
55
|
- 端口转发配置。
|
|
56
56
|
|
|
57
|
-
例如可以把几台机器分别备注为 `8xH100
|
|
57
|
+
例如可以把几台机器分别备注为 `8xH100 训练机`、`预发布环境`、`线上只读机`。这些配置保存在本地,重启 Pi 后仍然存在。
|
|
58
58
|
|
|
59
59
|
### 断线后可以自动恢复
|
|
60
60
|
|
|
@@ -109,12 +109,6 @@ pi install npm:pi-ssh-remote
|
|
|
109
109
|
/remote config note H100 训练机
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
然后直接告诉 Pi:
|
|
113
|
-
|
|
114
|
-
```text
|
|
115
|
-
添加一条服务器记忆:使用 /opt/conda/bin/python,不要停止其他用户的共享任务。
|
|
116
|
-
```
|
|
117
|
-
|
|
118
112
|
查看当前状态:
|
|
119
113
|
|
|
120
114
|
```text
|
|
@@ -129,6 +123,29 @@ pi install npm:pi-ssh-remote
|
|
|
129
123
|
/remote off
|
|
130
124
|
```
|
|
131
125
|
|
|
126
|
+
### 服务器记忆
|
|
127
|
+
|
|
128
|
+
直接告诉 Pi 要为当前服务器记住什么:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
记住这台服务器使用 /opt/conda/bin/python,不要停止共享任务。
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
查看已有记忆和 ID:
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
/remote memory
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
通过 ID 修改或删除:
|
|
141
|
+
|
|
142
|
+
```text
|
|
143
|
+
把记忆 deploy-command 改为使用 /opt/deploy/v2/run.sh。
|
|
144
|
+
删除记忆 obsolete-proxy-rule。
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
以后连接这台服务器时,Pi 会自动使用这些记忆。只有明确要求删除时才会删除。
|
|
148
|
+
|
|
132
149
|
### 使用私钥文件登录
|
|
133
150
|
|
|
134
151
|
通过 `-i` 指定本地私钥:
|
|
@@ -188,7 +205,7 @@ pi install npm:pi-ssh-remote
|
|
|
188
205
|
/remote
|
|
189
206
|
```
|
|
190
207
|
|
|
191
|
-
备注和默认目录会按 `user@host:port`
|
|
208
|
+
备注和默认目录会按 `user@host:port` 分别保存,不会互相覆盖。
|
|
192
209
|
|
|
193
210
|
### 4. 远端启动模型,本地开发界面
|
|
194
211
|
|
|
@@ -223,25 +240,6 @@ pi install npm:pi-ssh-remote
|
|
|
223
240
|
把远端 8000 端口转发到本地 8000,但代码工具继续留在本地。
|
|
224
241
|
```
|
|
225
242
|
|
|
226
|
-
```text
|
|
227
|
-
添加一条服务器记忆:部署必须使用 /opt/deploy/run.sh。
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
更新或删除前,可以先展示当前服务器的全部记忆和 ID:
|
|
231
|
-
|
|
232
|
-
```text
|
|
233
|
-
/remote memory
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
然后使用展示出的 ID:
|
|
237
|
-
|
|
238
|
-
```text
|
|
239
|
-
把 ID 为 deploy-command 的记忆更新为使用 /opt/deploy/v2/run.sh。
|
|
240
|
-
删除 ID 为 obsolete-proxy-rule 的服务器记忆。
|
|
241
|
-
```
|
|
242
|
-
|
|
243
|
-
Pi 会通过插件提供的 `remote` 工具完成这些操作。`memory` action 只负责定位和查看匹配的 JSON 文件,实际增删查改使用 Pi 自带的 `read`、`edit`、`write`。删除规则会明确注入给 Agent:只有用户明确要求“删除/移除/忘记”服务器记忆时,Pi 才能先读取文件、确认准确条目 ID,再只删除该对象;目标不明确时必须询问,修改请求不等于删除授权,删除全部条目也必须由用户明确提出。
|
|
244
|
-
|
|
245
243
|
## 命令说明
|
|
246
244
|
|
|
247
245
|
| 命令 | 作用 |
|
|
@@ -252,7 +250,7 @@ Pi 会通过插件提供的 `remote` 工具完成这些操作。`memory` action
|
|
|
252
250
|
| `/remote use USER@HOST:PORT` | 切换到指定服务器 |
|
|
253
251
|
| `/remote config note TEXT` | 给当前服务器添加或修改备注 |
|
|
254
252
|
| `/remote config note --clear` | 清除当前服务器备注 |
|
|
255
|
-
| `/remote memory` |
|
|
253
|
+
| `/remote memory` | 展示当前服务器的记忆条目和 ID |
|
|
256
254
|
| `/remote config cwd PATH` | 设置默认远程工作目录 |
|
|
257
255
|
| `/remote cd PATH` | 切换当前远程目录并保存 |
|
|
258
256
|
| `/remote config forward MAPPING...` | 保存端口转发配置,例如 `7860:127.0.0.1:7860` |
|
|
@@ -291,15 +289,9 @@ Pi 会通过插件提供的 `remote` 工具完成这些操作。`memory` action
|
|
|
291
289
|
- 命令预览设置;
|
|
292
290
|
- 模型输出预算。
|
|
293
291
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
```text
|
|
297
|
-
~/.pi/agent/ssh-remote-memories/
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
文件结构为 `{ "server", "entries": [{ "id", "content" }] }`,旧版字符串记忆会自动迁移为 JSON 条目。此外,每个 Pi session 都会记录不含凭据的 SSH 工作区元数据,用于在 `/resume` 时恢复该历史 session 对应的服务器环境。服务器记忆按 `user@host` 识别,不受 SSH 端口影响。它是由用户在本地配置的可信上下文,不会从远程服务器自动读取;仅当匹配的 endpoint 作为远端工作区启用时才会加入每次模型请求。远端路由启用时,只有当前服务器对应的精确记忆文件路径会被 `read`、`edit`、`write` 特殊处理为本地文件。
|
|
292
|
+
此外,每个 Pi session 都会记录不含凭据的 SSH 工作区元数据,用于在 `/resume` 时恢复该历史 session 对应的服务器环境。
|
|
301
293
|
|
|
302
|
-
私钥内容、密码和密钥 passphrase
|
|
294
|
+
私钥内容、密码和密钥 passphrase 都不会写入配置文件。插件仅在连接时从本地读取私钥;手动输入的密码和 passphrase 只会缓存在当前 Pi 进程的内存中。
|
|
303
295
|
|
|
304
296
|
## 安全与输出限制
|
|
305
297
|
|
|
@@ -320,13 +312,13 @@ Pi 会通过插件提供的 `remote` 工具完成这些操作。`memory` action
|
|
|
320
312
|
|
|
321
313
|
## 版本发布
|
|
322
314
|
|
|
323
|
-
最新版本:[v0.1.
|
|
315
|
+
最新版本:[v0.1.13](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.13)
|
|
324
316
|
|
|
325
317
|
| 版本 | 日期 | 主要内容 |
|
|
326
318
|
|---|---|---|
|
|
319
|
+
| [0.1.13](CHANGELOG.md#0113---2026-09-18) | 2026-09-18 | 工作目录不可用时平滑回退、延长 SSH 握手等待并修复 Windows 路径 |
|
|
320
|
+
| [0.1.12](CHANGELOG.md#0112---2026-08-25) | 2026-08-25 | 安全处理 SSH 握手期间断线所触发的连续错误 |
|
|
327
321
|
| [0.1.11](CHANGELOG.md#0111---2026-08-16) | 2026-08-16 | JSON 记忆条目、Prompt 引导增删查改与 `/remote memory` 展示命令 |
|
|
328
|
-
| [0.1.10](CHANGELOG.md#0110---2026-08-16) | 2026-08-16 | 默认安全读取且仅追加的服务器记忆 agent 更新逻辑 |
|
|
329
|
-
| [0.1.9](CHANGELOG.md#019---2026-08-12) | 2026-08-12 | 稳定且去重的服务器记忆上下文与更短的状态栏标签 |
|
|
330
322
|
|
|
331
323
|
完整的新增内容、行为变更和 Bug 修复记录请查看 [CHANGELOG.md](CHANGELOG.md)。
|
|
332
324
|
|
package/index.ts
CHANGED
|
@@ -10,12 +10,12 @@ import ssh2, { type Client as SshClient, type ClientChannel, type ConnectConfig,
|
|
|
10
10
|
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
11
11
|
import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
12
12
|
import { createServer, type Server, type Socket } from "node:net";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
13
|
+
import { homedir, tmpdir } from "node:os";
|
|
14
14
|
import { Type } from "typebox";
|
|
15
15
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
16
16
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import {
|
|
18
|
-
|
|
18
|
+
getAgentDir,
|
|
19
19
|
DEFAULT_MAX_BYTES,
|
|
20
20
|
DEFAULT_MAX_LINES,
|
|
21
21
|
createBashTool,
|
|
@@ -47,6 +47,7 @@ interface ParsedSsh {
|
|
|
47
47
|
interface RemoteState extends ParsedSsh {
|
|
48
48
|
client: SshClient;
|
|
49
49
|
cwd: string;
|
|
50
|
+
unavailableCwd?: string;
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
interface CredentialCache {
|
|
@@ -106,7 +107,8 @@ interface SessionRemoteState {
|
|
|
106
107
|
forwards?: string[];
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
|
|
110
|
+
// Use Pi's agent dir so Windows follows USERPROFILE and PI_CODING_AGENT_DIR is honored.
|
|
111
|
+
const AGENT_DIR = getAgentDir();
|
|
110
112
|
const KNOWN_HOSTS_FILE = join(AGENT_DIR, "ssh-remote-known-hosts.json");
|
|
111
113
|
const REMOTE_CONFIG_FILE = join(AGENT_DIR, "ssh-remote-config.json");
|
|
112
114
|
const SERVER_MEMORY_DIR = join(AGENT_DIR, "ssh-remote-memories");
|
|
@@ -120,6 +122,7 @@ const DEFAULT_EXEC_MAX_BYTES = 8 * 1024;
|
|
|
120
122
|
const DEFAULT_TURN_MAX_BYTES = 32 * 1024;
|
|
121
123
|
const MIN_MODEL_OUTPUT_BYTES = 1024;
|
|
122
124
|
const OUTPUT_FOOTER_RESERVE_BYTES = 512;
|
|
125
|
+
const SSH_HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
123
126
|
const DEFAULT_REMOTE_TIMEOUT_SECONDS = 30;
|
|
124
127
|
const MAX_REMOTE_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
|
|
125
128
|
const MAX_PRIVATE_KEY_BYTES = 1024 * 1024;
|
|
@@ -147,6 +150,10 @@ function shellWords(input: string): string[] {
|
|
|
147
150
|
return words;
|
|
148
151
|
}
|
|
149
152
|
|
|
153
|
+
function isHomeRelativePath(filePath: string): boolean {
|
|
154
|
+
return filePath === "~" || filePath.startsWith("~/");
|
|
155
|
+
}
|
|
156
|
+
|
|
150
157
|
function parseSshCommand(command: string): ParsedSsh {
|
|
151
158
|
const args = shellWords(command);
|
|
152
159
|
if (args[0] !== "ssh") throw new Error("Command must start with ssh, for example: ssh root@host -p 22");
|
|
@@ -175,7 +182,7 @@ function parseSshCommand(command: string): ParsedSsh {
|
|
|
175
182
|
else throw new Error("Unexpected extra argument in SSH command");
|
|
176
183
|
}
|
|
177
184
|
if (!target || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid SSH host or port");
|
|
178
|
-
if (identityFile &&
|
|
185
|
+
if (identityFile && !isHomeRelativePath(identityFile) && !isAbsolute(identityFile)) {
|
|
179
186
|
throw new Error("SSH identity file must use an absolute path or ~/...");
|
|
180
187
|
}
|
|
181
188
|
const at = target.lastIndexOf("@");
|
|
@@ -207,10 +214,9 @@ function deleteCachedPassword(config: ParsedSsh): void {
|
|
|
207
214
|
|
|
208
215
|
function resolveIdentityPath(config: ParsedSsh): string {
|
|
209
216
|
if (!config.identityFile) throw new Error("No SSH identity file is configured");
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return config.identityFile === "~" ? home : join(home, config.identityFile.slice(2));
|
|
217
|
+
// Expand ~ with the user home directory, not Pi's agent directory.
|
|
218
|
+
if (isHomeRelativePath(config.identityFile)) {
|
|
219
|
+
return config.identityFile === "~" ? homedir() : join(homedir(), config.identityFile.slice(2));
|
|
214
220
|
}
|
|
215
221
|
return config.identityFile;
|
|
216
222
|
}
|
|
@@ -690,7 +696,7 @@ function probeFingerprint(config: ParsedSsh): Promise<string> {
|
|
|
690
696
|
let settled = false;
|
|
691
697
|
const timer = setTimeout(() => {
|
|
692
698
|
if (!settled) { settled = true; client.end(); reject(new Error("Connection timed out")); }
|
|
693
|
-
},
|
|
699
|
+
}, SSH_HANDSHAKE_TIMEOUT_MS + 2000);
|
|
694
700
|
client.on("error", (error) => {
|
|
695
701
|
if (!settled) { settled = true; clearTimeout(timer); reject(error); }
|
|
696
702
|
});
|
|
@@ -698,7 +704,7 @@ function probeFingerprint(config: ParsedSsh): Promise<string> {
|
|
|
698
704
|
host: config.host,
|
|
699
705
|
port: config.port,
|
|
700
706
|
username: config.username,
|
|
701
|
-
readyTimeout:
|
|
707
|
+
readyTimeout: SSH_HANDSHAKE_TIMEOUT_MS,
|
|
702
708
|
hostHash: "sha256",
|
|
703
709
|
hostVerifier: (hash) => {
|
|
704
710
|
if (!settled) { settled = true; clearTimeout(timer); resolve(hash); }
|
|
@@ -719,14 +725,18 @@ function connect(config: ParsedSsh, authentication: SshAuthentication, fingerpri
|
|
|
719
725
|
port: config.port,
|
|
720
726
|
username: config.username,
|
|
721
727
|
...authentication,
|
|
722
|
-
readyTimeout:
|
|
728
|
+
readyTimeout: SSH_HANDSHAKE_TIMEOUT_MS,
|
|
723
729
|
keepaliveInterval: 15000,
|
|
724
730
|
keepaliveCountMax: 3,
|
|
725
731
|
hostHash: "sha256",
|
|
726
732
|
hostVerifier: (hash) => hash === fingerprint,
|
|
727
733
|
};
|
|
728
734
|
client.once("ready", () => resolve(client));
|
|
729
|
-
|
|
735
|
+
// ssh2 may emit a socket error followed by a protocol error while a
|
|
736
|
+
// connection is lost during handshake. Keep consuming client errors after
|
|
737
|
+
// the first one so EventEmitter does not turn the follow-up into an
|
|
738
|
+
// uncaught exception; rejecting an already-settled promise is a no-op.
|
|
739
|
+
client.on("error", reject);
|
|
730
740
|
client.connect(options);
|
|
731
741
|
});
|
|
732
742
|
}
|
|
@@ -1034,8 +1044,16 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1034
1044
|
const client = await connect(parsed, authentication, fingerprint);
|
|
1035
1045
|
try {
|
|
1036
1046
|
const cdCommand = cwd === FALLBACK_REMOTE_CWD ? "cd -- ~" : `cd -- ${quote(cwd)}`;
|
|
1037
|
-
|
|
1038
|
-
|
|
1047
|
+
let resolved: string;
|
|
1048
|
+
let unavailableCwd: string | undefined;
|
|
1049
|
+
try {
|
|
1050
|
+
resolved = (await execRemote(client, `${cdCommand} && pwd -P`)).toString().trim();
|
|
1051
|
+
} catch (error) {
|
|
1052
|
+
if (cwd === FALLBACK_REMOTE_CWD) throw error;
|
|
1053
|
+
unavailableCwd = cwd;
|
|
1054
|
+
resolved = (await execRemote(client, "cd -- ~ && pwd -P")).toString().trim();
|
|
1055
|
+
}
|
|
1056
|
+
const state = { ...parsed, client, cwd: resolved, ...(unavailableCwd ? { unavailableCwd } : {}) };
|
|
1039
1057
|
attachClient(state);
|
|
1040
1058
|
return state;
|
|
1041
1059
|
} catch (error) {
|
|
@@ -1063,7 +1081,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1063
1081
|
oldClient?.end();
|
|
1064
1082
|
if (currentCtx) {
|
|
1065
1083
|
status(currentCtx);
|
|
1066
|
-
|
|
1084
|
+
if (next.unavailableCwd) {
|
|
1085
|
+
currentCtx.ui.notify(`SSH remote directory unavailable: ${next.unavailableCwd}; reconnected in default directory ${next.cwd}`, "warning");
|
|
1086
|
+
} else {
|
|
1087
|
+
currentCtx.ui.notify(`SSH remote reconnected automatically: ${endpointDisplayLabel(next)}:${next.cwd}`, "info");
|
|
1088
|
+
}
|
|
1067
1089
|
}
|
|
1068
1090
|
return next;
|
|
1069
1091
|
})().finally(() => { reconnectPromise = null; });
|
|
@@ -1164,7 +1186,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1164
1186
|
saveEndpointConfig(command, { remoteCwd: next.cwd }, true);
|
|
1165
1187
|
status(ctx);
|
|
1166
1188
|
persistSessionRemoteState();
|
|
1167
|
-
|
|
1189
|
+
if (next.unavailableCwd) {
|
|
1190
|
+
ctx.ui.notify(`SSH remote directory unavailable: ${next.unavailableCwd}; connected in default directory ${next.cwd}`, "warning");
|
|
1191
|
+
} else {
|
|
1192
|
+
ctx.ui.notify(`SSH remote connected: ${endpointDisplayLabel(next)}:${next.cwd}`, "info");
|
|
1193
|
+
}
|
|
1168
1194
|
return next;
|
|
1169
1195
|
} catch (error) {
|
|
1170
1196
|
if (!parsed.identityFile) deleteCachedPassword(parsed);
|