pi-ssh-remote 0.1.5 → 0.1.7
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 +156 -0
- package/README.md +17 -97
- package/README.zh-CN.md +27 -102
- package/index.ts +470 -48
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable user-visible changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Release dates are npm publication dates, and versions follow this repository's pre-1.0 versioning policy documented in `AGENTS.md`.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- None.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- None.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- None.
|
|
20
|
+
|
|
21
|
+
## [0.1.7] - 2026-08-05
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Added non-secret, session-scoped SSH workspace state for endpoint, remote directory, tool-routing mode, and port forwards.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- `/new` now inherits the active SSH workspace, forks and clones retain their source workspace, and `/resume` restores the selected historical session's recorded workspace.
|
|
30
|
+
- Session changes close the previous transport cleanly and reconnect the target workspace without storing passwords or other credentials in session files.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- None.
|
|
35
|
+
|
|
36
|
+
## [0.1.6] - 2026-08-05
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- Added configurable line and byte budgets for remote reads, remote commands, and aggregate per-turn tool output.
|
|
41
|
+
- Added bounded range reads for remote text files with normal `offset` and `limit` continuation.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- Remote text reads now default to 400 lines or 16 KB, remote command results default to the last 200 lines or 8 KB, and each agent turn defaults to a shared 32 KB remote output budget.
|
|
46
|
+
- Remote command output now streams through bounded buffers, with complete oversized stdout written to a permission-restricted local temporary file.
|
|
47
|
+
- Collapsed command previews are now limited to at most 50 visual lines and remain independent from model-facing output.
|
|
48
|
+
- Moved the complete release history into this changelog and kept concise release indexes in the English and Chinese READMEs.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- Fixed remote text reads downloading complete files over SFTP before applying line and byte limits.
|
|
53
|
+
- Fixed `ssh_remote_control exec` accumulating complete stdout in memory before truncation.
|
|
54
|
+
|
|
55
|
+
## [0.1.5] - 2026-08-04
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
- Added persistent, endpoint-specific server memory through `/remote config memory TEXT` and the `ssh_remote_control` `memory` action.
|
|
60
|
+
- Added automatic injection of that memory into every model request while the matching endpoint is the active remote workspace.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- Server memory is removed from subsequent model requests when remote tool routing is disabled, including disconnect and tunnel-only mode.
|
|
65
|
+
- `/remote config` now shows the memory stored for each endpoint.
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- None.
|
|
70
|
+
|
|
71
|
+
## [0.1.4] - 2026-07-31
|
|
72
|
+
|
|
73
|
+
### Added
|
|
74
|
+
|
|
75
|
+
- Added persistent endpoint notes through `/remote config note TEXT` and the `ssh_remote_control` `note` action.
|
|
76
|
+
- Added per-command timeout overrides through `/remote exec --timeout SECONDS` and the tool's `timeout` parameter.
|
|
77
|
+
|
|
78
|
+
### Changed
|
|
79
|
+
|
|
80
|
+
- Remote commands now default to a 30-second timeout and endpoint notes appear in status, connection, reconnection, and footer labels.
|
|
81
|
+
- Expanded the documentation around agent-first workflows, multi-server operation, background jobs, and tunnel mode.
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
|
|
85
|
+
- Fixed remote commands that could run without a deadline by enforcing GNU `timeout` remotely and a local fallback timer.
|
|
86
|
+
- Fixed timeout reporting for processes terminated with timeout-related exit codes 124 and 137.
|
|
87
|
+
|
|
88
|
+
## [0.1.3] - 2026-07-24
|
|
89
|
+
|
|
90
|
+
### Added
|
|
91
|
+
|
|
92
|
+
- Added configurable collapsed command previews with `/remote config display-lines N`, `/remote exec --lines N`, and the tool's `displayLines` parameter.
|
|
93
|
+
- Added expandable custom rendering for remote command results.
|
|
94
|
+
|
|
95
|
+
### Changed
|
|
96
|
+
|
|
97
|
+
- Remote command previews default to the last five visual lines while model-facing output remains limited to 2,000 lines or 50 KB.
|
|
98
|
+
- Oversized complete output is saved to a permission-restricted local temporary file.
|
|
99
|
+
|
|
100
|
+
### Fixed
|
|
101
|
+
|
|
102
|
+
- Fixed `/remote exec` truncating its notification to an arbitrary 4,000 characters without preserving or identifying the full output.
|
|
103
|
+
|
|
104
|
+
## [0.1.2] - 2026-07-24
|
|
105
|
+
|
|
106
|
+
### Added
|
|
107
|
+
|
|
108
|
+
- None.
|
|
109
|
+
|
|
110
|
+
### Changed
|
|
111
|
+
|
|
112
|
+
- Republished the 0.1.1 runtime as 0.1.2 to align npm package version metadata; there were no runtime code changes.
|
|
113
|
+
|
|
114
|
+
### Fixed
|
|
115
|
+
|
|
116
|
+
- None.
|
|
117
|
+
|
|
118
|
+
## [0.1.1] - 2026-07-24
|
|
119
|
+
|
|
120
|
+
### Added
|
|
121
|
+
|
|
122
|
+
- Added the shorter `/remote ssh USER@HOST [-p PORT]` connection command.
|
|
123
|
+
|
|
124
|
+
### Changed
|
|
125
|
+
|
|
126
|
+
- `/remote ssh` now saves, selects, and immediately connects to the endpoint instead of only storing it.
|
|
127
|
+
|
|
128
|
+
### Fixed
|
|
129
|
+
|
|
130
|
+
- Fixed help and error text that still instructed users to run the obsolete `/remote config ssh ssh ...` form.
|
|
131
|
+
|
|
132
|
+
## [0.1.0] - 2026-07-24
|
|
133
|
+
|
|
134
|
+
### Added
|
|
135
|
+
|
|
136
|
+
- Initial release with persistent multi-endpoint SSH workspaces and remote working directories.
|
|
137
|
+
- Added transparent routing for Pi's `read`, `write`, `edit`, `bash`, and user shell operations.
|
|
138
|
+
- Added SSH-agent/password authentication, interactive host-key verification, automatic reconnection, SFTP file access, bounded command output, and local TCP forwarding.
|
|
139
|
+
|
|
140
|
+
### Changed
|
|
141
|
+
|
|
142
|
+
- None.
|
|
143
|
+
|
|
144
|
+
### Fixed
|
|
145
|
+
|
|
146
|
+
- None.
|
|
147
|
+
|
|
148
|
+
[Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.7...HEAD
|
|
149
|
+
[0.1.7]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.6...v0.1.7
|
|
150
|
+
[0.1.6]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.5...v0.1.6
|
|
151
|
+
[0.1.5]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.4...v0.1.5
|
|
152
|
+
[0.1.4]: https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.4
|
|
153
|
+
[0.1.3]: https://www.npmjs.com/package/pi-ssh-remote/v/0.1.3
|
|
154
|
+
[0.1.2]: https://www.npmjs.com/package/pi-ssh-remote/v/0.1.2
|
|
155
|
+
[0.1.1]: https://www.npmjs.com/package/pi-ssh-remote/v/0.1.1
|
|
156
|
+
[0.1.0]: https://www.npmjs.com/package/pi-ssh-remote/v/0.1.0
|
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Each `user@host:port` endpoint remembers its remote working directory, note, ser
|
|
|
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,
|
|
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
|
|
|
@@ -176,7 +176,12 @@ These requests are handled through the agent-facing `ssh_remote_control` tool; s
|
|
|
176
176
|
| `/remote exec COMMAND` | Run one command in the remote cwd |
|
|
177
177
|
| `/remote exec --timeout 60 COMMAND` | Override the command timeout |
|
|
178
178
|
| `/remote exec --lines 20 COMMAND` | Override collapsed preview lines |
|
|
179
|
-
| `/remote config display-lines 10` | Set default collapsed preview lines |
|
|
179
|
+
| `/remote config display-lines 10` | Set default collapsed preview lines (maximum 50) |
|
|
180
|
+
| `/remote config read-max-lines 400` | Set the remote read line budget |
|
|
181
|
+
| `/remote config read-max-bytes 16384` | Set the remote read byte budget |
|
|
182
|
+
| `/remote config exec-max-lines 200` | Set the remote command line budget |
|
|
183
|
+
| `/remote config exec-max-bytes 8192` | Set the remote command byte budget |
|
|
184
|
+
| `/remote config turn-max-bytes 32768` | Set the aggregate per-turn remote output budget |
|
|
180
185
|
| `/remote status` | Show the active workspace |
|
|
181
186
|
| `/remote reload` | Reconnect the active workspace |
|
|
182
187
|
| `/remote off` | Disconnect and return tools to local execution |
|
|
@@ -190,109 +195,24 @@ Endpoint configuration is stored locally in:
|
|
|
190
195
|
~/.pi/agent/ssh-remote-config.json
|
|
191
196
|
```
|
|
192
197
|
|
|
193
|
-
Saved values include endpoints, active endpoint, notes, server-specific memories, remote working directories, forwards, and
|
|
198
|
+
Saved global values include endpoints, active endpoint, notes, server-specific memories, remote working directories, forwards, 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 that endpoint is the active remote workspace; it is not read from the remote server. Passwords are **never written to this file**: SSH agent authentication is preferred, and prompted passwords remain only in process memory.
|
|
194
199
|
|
|
195
|
-
New or changed host keys require interactive confirmation and are stored separately from OpenSSH.
|
|
200
|
+
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.
|
|
196
201
|
|
|
197
202
|
## Current SSH scope
|
|
198
203
|
|
|
199
204
|
The extension currently supports direct SSH commands with `-p` and `-l`. It does not yet consume `~/.ssh/config`, `IdentityFile`, or ProxyJump settings.
|
|
200
205
|
|
|
201
|
-
##
|
|
206
|
+
## Releases
|
|
202
207
|
|
|
203
|
-
|
|
208
|
+
Latest release: [v0.1.7](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.7)
|
|
204
209
|
|
|
205
|
-
|
|
210
|
+
| Version | Date | Highlights |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| [0.1.7](CHANGELOG.md#017---2026-08-05) | 2026-08-05 | Session-aware SSH workspace restoration for new, forked, and resumed sessions |
|
|
213
|
+
| [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 |
|
|
206
215
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
- Added persistent, endpoint-specific server memory through `/remote config memory TEXT` and the `ssh_remote_control` `memory` action.
|
|
210
|
-
- Added automatic injection of that memory into every model request while the matching endpoint is the active remote workspace.
|
|
211
|
-
|
|
212
|
-
**Changed**
|
|
213
|
-
|
|
214
|
-
- Server memory is removed from subsequent model requests when remote tool routing is disabled, including disconnect and tunnel-only mode.
|
|
215
|
-
- `/remote config` now shows the memory stored for each endpoint.
|
|
216
|
-
|
|
217
|
-
**Fixed**
|
|
218
|
-
|
|
219
|
-
- None.
|
|
220
|
-
|
|
221
|
-
### 0.1.4 — 2026-07-31
|
|
222
|
-
|
|
223
|
-
**Added**
|
|
224
|
-
|
|
225
|
-
- Added persistent endpoint notes through `/remote config note TEXT` and the `ssh_remote_control` `note` action.
|
|
226
|
-
- Added per-command timeout overrides through `/remote exec --timeout SECONDS` and the tool's `timeout` parameter.
|
|
227
|
-
|
|
228
|
-
**Changed**
|
|
229
|
-
|
|
230
|
-
- Remote commands now default to a 30-second timeout and endpoint notes appear in status, connection, reconnection, and footer labels.
|
|
231
|
-
- Expanded the documentation around agent-first workflows, multi-server operation, background jobs, and tunnel mode.
|
|
232
|
-
|
|
233
|
-
**Fixed**
|
|
234
|
-
|
|
235
|
-
- Fixed remote commands that could run without a deadline by enforcing GNU `timeout` remotely and a local fallback timer.
|
|
236
|
-
- Fixed timeout reporting for processes terminated with timeout-related exit codes 124 and 137.
|
|
237
|
-
|
|
238
|
-
### 0.1.3 — 2026-07-24
|
|
239
|
-
|
|
240
|
-
**Added**
|
|
241
|
-
|
|
242
|
-
- Added configurable collapsed command previews with `/remote config display-lines N`, `/remote exec --lines N`, and the tool's `displayLines` parameter.
|
|
243
|
-
- Added expandable custom rendering for remote command results.
|
|
244
|
-
|
|
245
|
-
**Changed**
|
|
246
|
-
|
|
247
|
-
- Remote command previews default to the last five visual lines while model-facing output remains limited to 2,000 lines or 50 KB.
|
|
248
|
-
- Oversized complete output is saved to a permission-restricted local temporary file.
|
|
249
|
-
|
|
250
|
-
**Fixed**
|
|
251
|
-
|
|
252
|
-
- Fixed `/remote exec` truncating its notification to an arbitrary 4,000 characters without preserving or identifying the full output.
|
|
253
|
-
|
|
254
|
-
### 0.1.2 — 2026-07-24
|
|
255
|
-
|
|
256
|
-
**Added**
|
|
257
|
-
|
|
258
|
-
- None.
|
|
259
|
-
|
|
260
|
-
**Changed**
|
|
261
|
-
|
|
262
|
-
- Republished the 0.1.1 runtime as 0.1.2 to align npm package version metadata; there were no runtime code changes.
|
|
263
|
-
|
|
264
|
-
**Fixed**
|
|
265
|
-
|
|
266
|
-
- None.
|
|
267
|
-
|
|
268
|
-
### 0.1.1 — 2026-07-24
|
|
269
|
-
|
|
270
|
-
**Added**
|
|
271
|
-
|
|
272
|
-
- Added the shorter `/remote ssh USER@HOST [-p PORT]` connection command.
|
|
273
|
-
|
|
274
|
-
**Changed**
|
|
275
|
-
|
|
276
|
-
- `/remote ssh` now saves, selects, and immediately connects to the endpoint instead of only storing it.
|
|
277
|
-
|
|
278
|
-
**Fixed**
|
|
279
|
-
|
|
280
|
-
- Fixed help and error text that still instructed users to run the obsolete `/remote config ssh ssh ...` form.
|
|
281
|
-
|
|
282
|
-
### 0.1.0 — 2026-07-24
|
|
283
|
-
|
|
284
|
-
**Added**
|
|
285
|
-
|
|
286
|
-
- Initial release with persistent multi-endpoint SSH workspaces and remote working directories.
|
|
287
|
-
- Added transparent routing for Pi's `read`, `write`, `edit`, `bash`, and user shell operations.
|
|
288
|
-
- Added SSH-agent/password authentication, interactive host-key verification, automatic reconnection, SFTP file access, bounded command output, and local TCP forwarding.
|
|
289
|
-
|
|
290
|
-
**Changed**
|
|
291
|
-
|
|
292
|
-
- None.
|
|
293
|
-
|
|
294
|
-
**Fixed**
|
|
295
|
-
|
|
296
|
-
- None.
|
|
216
|
+
See [CHANGELOG.md](CHANGELOG.md) for the complete release history, including additions, behavior changes, and bug fixes.
|
|
297
217
|
|
|
298
218
|
MIT licensed.
|
package/README.zh-CN.md
CHANGED
|
@@ -58,11 +58,11 @@ SSH remote H100 训练机 (root@gpu-box.example.com:2202):/srv/project
|
|
|
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
|
|
|
65
|
-
远程命令默认 30 秒超时,避免某个前台任务长期占住 Agent
|
|
65
|
+
远程命令默认 30 秒超时,避免某个前台任务长期占住 Agent。远程文本读取会按范围获取,不再先下载完整文件;命令输出通过有界缓冲流式处理;同一轮远端工具共享 32 KB 输出预算,避免并行调用迅速占满上下文。超限命令的完整输出会保存到权限受限的临时文件,方便后续按需检查。
|
|
66
66
|
|
|
67
67
|
### 可以远端跑服务、本地改代码
|
|
68
68
|
|
|
@@ -230,7 +230,12 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
|
|
|
230
230
|
| `/remote exec COMMAND` | 在当前远程目录执行一次命令 |
|
|
231
231
|
| `/remote exec --timeout 60 COMMAND` | 单独设置本次命令的超时时间 |
|
|
232
232
|
| `/remote exec --lines 20 COMMAND` | 单独设置本次折叠显示的行数 |
|
|
233
|
-
| `/remote config display-lines 10` |
|
|
233
|
+
| `/remote config display-lines 10` | 设置默认折叠显示行数(最大 50) |
|
|
234
|
+
| `/remote config read-max-lines 400` | 设置远端读取行数预算 |
|
|
235
|
+
| `/remote config read-max-bytes 16384` | 设置远端读取字节预算 |
|
|
236
|
+
| `/remote config exec-max-lines 200` | 设置远端命令行数预算 |
|
|
237
|
+
| `/remote config exec-max-bytes 8192` | 设置远端命令字节预算 |
|
|
238
|
+
| `/remote config turn-max-bytes 32768` | 设置每轮远端工具总输出预算 |
|
|
234
239
|
| `/remote status` | 查看当前连接和工作目录 |
|
|
235
240
|
| `/remote reload` | 重新连接当前服务器 |
|
|
236
241
|
| `/remote off` | 断开连接并返回本地 |
|
|
@@ -244,7 +249,7 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
|
|
|
244
249
|
~/.pi/agent/ssh-remote-config.json
|
|
245
250
|
```
|
|
246
251
|
|
|
247
|
-
|
|
252
|
+
全局配置包括:
|
|
248
253
|
|
|
249
254
|
- 已保存的服务器;
|
|
250
255
|
- 当前选中的服务器;
|
|
@@ -252,9 +257,10 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
|
|
|
252
257
|
- 服务器特定记忆;
|
|
253
258
|
- 默认远程目录;
|
|
254
259
|
- 端口转发配置;
|
|
255
|
-
-
|
|
260
|
+
- 命令预览设置;
|
|
261
|
+
- 模型输出预算。
|
|
256
262
|
|
|
257
|
-
|
|
263
|
+
此外,每个 Pi session 都会记录不含凭据的 SSH 工作区元数据,用于在 `/resume` 时恢复该历史 session 对应的服务器环境。服务器记忆是由用户在本地配置的可信上下文,不会从远程服务器自动读取;仅当对应 endpoint 作为远端工作区启用时才会加入每次模型请求。
|
|
258
264
|
|
|
259
265
|
密码不会写入配置文件。插件会优先使用 SSH agent;如果需要手动输入密码,密码只会缓存在当前 Pi 进程的内存中。
|
|
260
266
|
|
|
@@ -263,109 +269,28 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
|
|
|
263
269
|
- 第一次连接新服务器时,需要确认主机密钥;
|
|
264
270
|
- 主机密钥发生变化时,会再次要求确认;
|
|
265
271
|
- 远程命令默认 30 秒超时;
|
|
266
|
-
-
|
|
267
|
-
-
|
|
268
|
-
-
|
|
272
|
+
- 远程文本读取默认最多返回 400 行或 16 KB,并支持通过 `offset`/`limit` 继续读取;
|
|
273
|
+
- 远程命令默认最多返回最后 200 行或 8 KB;
|
|
274
|
+
- 同一轮所有远端工具默认共享 32 KB 模型输出预算;
|
|
275
|
+
- 文本范围读取不会先通过 SFTP 下载完整远程文件;
|
|
276
|
+
- 超限命令会流式写入权限受限的本地临时文件,不在内存中累积完整输出;
|
|
277
|
+
- 各项限制可以配置,但不能超过扩展的硬安全上限;
|
|
278
|
+
- 折叠显示行数最大为 50,只影响界面,不会增加模型输出。
|
|
269
279
|
|
|
270
280
|
## 当前限制
|
|
271
281
|
|
|
272
282
|
目前只支持 SSH 直连,以及 `-p`、`-l` 参数。暂不读取 `~/.ssh/config`,也不支持 `IdentityFile` 和 ProxyJump。
|
|
273
283
|
|
|
274
|
-
##
|
|
284
|
+
## 版本发布
|
|
275
285
|
|
|
276
|
-
|
|
286
|
+
最新版本:[v0.1.7](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.7)
|
|
277
287
|
|
|
278
|
-
|
|
288
|
+
| 版本 | 日期 | 主要内容 |
|
|
289
|
+
|---|---|---|
|
|
290
|
+
| [0.1.7](CHANGELOG.md#017---2026-08-05) | 2026-08-05 | 新建、分叉和恢复 session 时自动恢复对应 SSH 工作区 |
|
|
291
|
+
| [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 级服务器记忆与自动上下文注入 |
|
|
279
293
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
- 新增 endpoint 级持久化服务器记忆,支持 `/remote config memory TEXT` 和 `ssh_remote_control` 的 `memory` action。
|
|
283
|
-
- 对应 endpoint 作为当前远程工作区时,在每次模型请求中自动注入服务器记忆。
|
|
284
|
-
|
|
285
|
-
**变更**
|
|
286
|
-
|
|
287
|
-
- 断开连接、关闭远端工具路由或进入纯隧道模式后,后续模型请求不再包含服务器记忆。
|
|
288
|
-
- `/remote config` 现在会显示每个 endpoint 保存的记忆。
|
|
289
|
-
|
|
290
|
-
**修复**
|
|
291
|
-
|
|
292
|
-
- 无。
|
|
293
|
-
|
|
294
|
-
### 0.1.4 — 2026-07-31
|
|
295
|
-
|
|
296
|
-
**新增**
|
|
297
|
-
|
|
298
|
-
- 新增 endpoint 备注,支持 `/remote config note TEXT` 和 `ssh_remote_control` 的 `note` action。
|
|
299
|
-
- 新增单次命令超时覆盖,支持 `/remote exec --timeout SECONDS` 和工具的 `timeout` 参数。
|
|
300
|
-
|
|
301
|
-
**变更**
|
|
302
|
-
|
|
303
|
-
- 远程命令默认超时改为 30 秒;备注会显示在状态、连接、重连提示和底部状态栏中。
|
|
304
|
-
- 扩充了 Agent 工作流、多服务器、后台任务和纯隧道模式文档。
|
|
305
|
-
|
|
306
|
-
**修复**
|
|
307
|
-
|
|
308
|
-
- 修复部分远程命令没有截止时间、可能一直占用 Agent 的问题:远端使用 GNU `timeout`,本地增加兜底定时器。
|
|
309
|
-
- 修复退出码 124 和 137 未被统一识别为超时的问题。
|
|
310
|
-
|
|
311
|
-
### 0.1.3 — 2026-07-24
|
|
312
|
-
|
|
313
|
-
**新增**
|
|
314
|
-
|
|
315
|
-
- 新增可配置的命令折叠预览,支持 `/remote config display-lines N`、`/remote exec --lines N` 和工具的 `displayLines` 参数。
|
|
316
|
-
- 新增远程命令结果的展开显示。
|
|
317
|
-
|
|
318
|
-
**变更**
|
|
319
|
-
|
|
320
|
-
- 命令折叠预览默认显示最后 5 个视觉行;发送给模型的输出仍限制为 2,000 行或 50 KB。
|
|
321
|
-
- 超限的完整输出会保存到权限受限的本地临时文件。
|
|
322
|
-
|
|
323
|
-
**修复**
|
|
324
|
-
|
|
325
|
-
- 修复 `/remote exec` 直接按 4,000 字符截断通知、且不保留或提示完整输出位置的问题。
|
|
326
|
-
|
|
327
|
-
### 0.1.2 — 2026-07-24
|
|
328
|
-
|
|
329
|
-
**新增**
|
|
330
|
-
|
|
331
|
-
- 无。
|
|
332
|
-
|
|
333
|
-
**变更**
|
|
334
|
-
|
|
335
|
-
- 将 0.1.1 的运行时代码以 0.1.2 重新发布,用于对齐 npm 包版本元数据;没有运行时代码变化。
|
|
336
|
-
|
|
337
|
-
**修复**
|
|
338
|
-
|
|
339
|
-
- 无。
|
|
340
|
-
|
|
341
|
-
### 0.1.1 — 2026-07-24
|
|
342
|
-
|
|
343
|
-
**新增**
|
|
344
|
-
|
|
345
|
-
- 新增简化命令 `/remote ssh USER@HOST [-p PORT]`。
|
|
346
|
-
|
|
347
|
-
**变更**
|
|
348
|
-
|
|
349
|
-
- `/remote ssh` 现在会保存、选中并立即连接 endpoint,而不只是保存配置。
|
|
350
|
-
|
|
351
|
-
**修复**
|
|
352
|
-
|
|
353
|
-
- 修复帮助和错误信息仍提示使用旧命令 `/remote config ssh ssh ...` 的问题。
|
|
354
|
-
|
|
355
|
-
### 0.1.0 — 2026-07-24
|
|
356
|
-
|
|
357
|
-
**新增**
|
|
358
|
-
|
|
359
|
-
- 首次发布,支持持久化多 endpoint SSH 工作区和远程工作目录。
|
|
360
|
-
- 新增 Pi `read`、`write`、`edit`、`bash` 和用户 Shell 操作的透明远端路由。
|
|
361
|
-
- 新增 SSH agent/密码认证、交互式主机密钥校验、自动重连、SFTP 文件访问、命令输出限制和本地 TCP 端口转发。
|
|
362
|
-
|
|
363
|
-
**变更**
|
|
364
|
-
|
|
365
|
-
- 无。
|
|
366
|
-
|
|
367
|
-
**修复**
|
|
368
|
-
|
|
369
|
-
- 无。
|
|
294
|
+
完整的新增内容、行为变更和 Bug 修复记录请查看 [CHANGELOG.md](CHANGELOG.md)。
|
|
370
295
|
|
|
371
296
|
MIT License。
|
package/index.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { Client, type ClientChannel, type ConnectConfig, type SFTPWrapper } from "ssh2";
|
|
10
|
-
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { dirname, join, relative, sep } from "node:path";
|
|
10
|
+
import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, writeFileSync, writeSync } from "node:fs";
|
|
11
|
+
import { dirname, 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";
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
createWriteTool,
|
|
25
25
|
formatSize,
|
|
26
26
|
keyHint,
|
|
27
|
+
truncateHead,
|
|
27
28
|
truncateTail,
|
|
28
29
|
type BashOperations,
|
|
29
30
|
type EditOperations,
|
|
@@ -47,7 +48,7 @@ interface RemoteState extends ParsedSsh {
|
|
|
47
48
|
|
|
48
49
|
interface CredentialCache {
|
|
49
50
|
passwords: Map<string, string>;
|
|
50
|
-
resume?: { command: string; cwd: string };
|
|
51
|
+
resume?: { command: string; cwd: string; routeRemoteTools: boolean; forwards?: string[] };
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
interface RemoteEndpointConfig {
|
|
@@ -62,6 +63,11 @@ interface RemoteConfig {
|
|
|
62
63
|
activeEndpoint?: string;
|
|
63
64
|
endpoints?: Record<string, RemoteEndpointConfig>;
|
|
64
65
|
displayLines?: number;
|
|
66
|
+
readMaxLines?: number;
|
|
67
|
+
readMaxBytes?: number;
|
|
68
|
+
execMaxLines?: number;
|
|
69
|
+
execMaxBytes?: number;
|
|
70
|
+
turnMaxBytes?: number;
|
|
65
71
|
/** Legacy fields migrated into endpoints on the next config write. */
|
|
66
72
|
sshCommand?: string;
|
|
67
73
|
remoteCwd?: string;
|
|
@@ -74,13 +80,31 @@ interface ForwardSpec {
|
|
|
74
80
|
remotePort: number;
|
|
75
81
|
}
|
|
76
82
|
|
|
83
|
+
interface SessionRemoteState {
|
|
84
|
+
version: 1;
|
|
85
|
+
connected: boolean;
|
|
86
|
+
command?: string;
|
|
87
|
+
cwd?: string;
|
|
88
|
+
routeRemoteTools?: boolean;
|
|
89
|
+
forwards?: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
const AGENT_DIR = join(process.env.HOME || ".", CONFIG_DIR_NAME, "agent");
|
|
78
93
|
const KNOWN_HOSTS_FILE = join(AGENT_DIR, "ssh-remote-known-hosts.json");
|
|
79
94
|
const REMOTE_CONFIG_FILE = join(AGENT_DIR, "ssh-remote-config.json");
|
|
80
95
|
const FALLBACK_REMOTE_CWD = "~";
|
|
81
96
|
const DEFAULT_DISPLAY_LINES = 5;
|
|
97
|
+
const MAX_DISPLAY_LINES = 50;
|
|
98
|
+
const DEFAULT_READ_MAX_LINES = 400;
|
|
99
|
+
const DEFAULT_READ_MAX_BYTES = 16 * 1024;
|
|
100
|
+
const DEFAULT_EXEC_MAX_LINES = 200;
|
|
101
|
+
const DEFAULT_EXEC_MAX_BYTES = 8 * 1024;
|
|
102
|
+
const DEFAULT_TURN_MAX_BYTES = 32 * 1024;
|
|
103
|
+
const MIN_MODEL_OUTPUT_BYTES = 1024;
|
|
104
|
+
const OUTPUT_FOOTER_RESERVE_BYTES = 512;
|
|
82
105
|
const DEFAULT_REMOTE_TIMEOUT_SECONDS = 30;
|
|
83
106
|
const MAX_REMOTE_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
|
|
107
|
+
const SESSION_STATE_ENTRY_TYPE = "pi-ssh-remote-state";
|
|
84
108
|
const CACHE_KEY = "__piHpcCredentialCacheV1";
|
|
85
109
|
const cacheHost = globalThis as typeof globalThis & { [CACHE_KEY]?: CredentialCache };
|
|
86
110
|
const credentialCache = cacheHost[CACHE_KEY] ??= { passwords: new Map<string, string>() };
|
|
@@ -158,12 +182,28 @@ function quote(value: string): string {
|
|
|
158
182
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
159
183
|
}
|
|
160
184
|
|
|
185
|
+
function parseBoundedInteger(value: unknown, label: string, maximum: number): number {
|
|
186
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
187
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > maximum) {
|
|
188
|
+
throw new Error(`${label} must be an integer from 1 to ${maximum}`);
|
|
189
|
+
}
|
|
190
|
+
return parsed;
|
|
191
|
+
}
|
|
192
|
+
|
|
161
193
|
function parseDisplayLines(value: unknown): number {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
194
|
+
return parseBoundedInteger(value, "Display lines", MAX_DISPLAY_LINES);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseOutputLines(value: unknown, label: string): number {
|
|
198
|
+
return parseBoundedInteger(value, label, DEFAULT_MAX_LINES);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parseOutputBytes(value: unknown, label: string, maximum = DEFAULT_MAX_BYTES): number {
|
|
202
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
203
|
+
if (!Number.isInteger(parsed) || parsed < MIN_MODEL_OUTPUT_BYTES || parsed > maximum) {
|
|
204
|
+
throw new Error(`${label} must be an integer from ${MIN_MODEL_OUTPUT_BYTES} to ${maximum}`);
|
|
165
205
|
}
|
|
166
|
-
return
|
|
206
|
+
return parsed;
|
|
167
207
|
}
|
|
168
208
|
|
|
169
209
|
function parseRemoteTimeout(value: unknown): number {
|
|
@@ -183,6 +223,15 @@ function configuredDisplayLines(config = loadRemoteConfig()): number {
|
|
|
183
223
|
catch { return DEFAULT_DISPLAY_LINES; }
|
|
184
224
|
}
|
|
185
225
|
|
|
226
|
+
function configuredOutputLimits(config = loadRemoteConfig()) {
|
|
227
|
+
const readMaxLines = (() => { try { return parseOutputLines(config.readMaxLines ?? DEFAULT_READ_MAX_LINES, "Read max lines"); } catch { return DEFAULT_READ_MAX_LINES; } })();
|
|
228
|
+
const readMaxBytes = (() => { try { return parseOutputBytes(config.readMaxBytes ?? DEFAULT_READ_MAX_BYTES, "Read max bytes"); } catch { return DEFAULT_READ_MAX_BYTES; } })();
|
|
229
|
+
const execMaxLines = (() => { try { return parseOutputLines(config.execMaxLines ?? DEFAULT_EXEC_MAX_LINES, "Exec max lines"); } catch { return DEFAULT_EXEC_MAX_LINES; } })();
|
|
230
|
+
const execMaxBytes = (() => { try { return parseOutputBytes(config.execMaxBytes ?? DEFAULT_EXEC_MAX_BYTES, "Exec max bytes"); } catch { return DEFAULT_EXEC_MAX_BYTES; } })();
|
|
231
|
+
const turnMaxBytes = (() => { try { return parseOutputBytes(config.turnMaxBytes ?? DEFAULT_TURN_MAX_BYTES, "Turn max bytes", DEFAULT_MAX_BYTES * 4); } catch { return DEFAULT_TURN_MAX_BYTES; } })();
|
|
232
|
+
return { readMaxLines, readMaxBytes, execMaxLines, execMaxBytes, turnMaxBytes };
|
|
233
|
+
}
|
|
234
|
+
|
|
186
235
|
function commandFromEndpointKey(key: string): string | undefined {
|
|
187
236
|
const match = key.match(/^([^@]+)@(.+):(\d+)$/);
|
|
188
237
|
if (!match) return undefined;
|
|
@@ -215,13 +264,27 @@ function normalizeRemoteConfig(config: RemoteConfig): RemoteConfig {
|
|
|
215
264
|
if (activeEndpoint && !endpoints[activeEndpoint]) activeEndpoint = undefined;
|
|
216
265
|
activeEndpoint ??= Object.keys(endpoints)[0];
|
|
217
266
|
let displayLines: number | undefined;
|
|
218
|
-
|
|
219
|
-
|
|
267
|
+
let readMaxLines: number | undefined;
|
|
268
|
+
let readMaxBytes: number | undefined;
|
|
269
|
+
let execMaxLines: number | undefined;
|
|
270
|
+
let execMaxBytes: number | undefined;
|
|
271
|
+
let turnMaxBytes: number | undefined;
|
|
272
|
+
try { displayLines = config.displayLines === undefined ? undefined : parseDisplayLines(config.displayLines); } catch {}
|
|
273
|
+
try { readMaxLines = config.readMaxLines === undefined ? undefined : parseOutputLines(config.readMaxLines, "Read max lines"); } catch {}
|
|
274
|
+
try { readMaxBytes = config.readMaxBytes === undefined ? undefined : parseOutputBytes(config.readMaxBytes, "Read max bytes"); } catch {}
|
|
275
|
+
try { execMaxLines = config.execMaxLines === undefined ? undefined : parseOutputLines(config.execMaxLines, "Exec max lines"); } catch {}
|
|
276
|
+
try { execMaxBytes = config.execMaxBytes === undefined ? undefined : parseOutputBytes(config.execMaxBytes, "Exec max bytes"); } catch {}
|
|
277
|
+
try { turnMaxBytes = config.turnMaxBytes === undefined ? undefined : parseOutputBytes(config.turnMaxBytes, "Turn max bytes", DEFAULT_MAX_BYTES * 4); } catch {}
|
|
220
278
|
|
|
221
279
|
return {
|
|
222
280
|
...(activeEndpoint ? { activeEndpoint } : {}),
|
|
223
281
|
...(Object.keys(endpoints).length ? { endpoints } : {}),
|
|
224
282
|
...(displayLines !== undefined ? { displayLines } : {}),
|
|
283
|
+
...(readMaxLines !== undefined ? { readMaxLines } : {}),
|
|
284
|
+
...(readMaxBytes !== undefined ? { readMaxBytes } : {}),
|
|
285
|
+
...(execMaxLines !== undefined ? { execMaxLines } : {}),
|
|
286
|
+
...(execMaxBytes !== undefined ? { execMaxBytes } : {}),
|
|
287
|
+
...(turnMaxBytes !== undefined ? { turnMaxBytes } : {}),
|
|
225
288
|
};
|
|
226
289
|
}
|
|
227
290
|
|
|
@@ -299,18 +362,99 @@ function displayFingerprint(hex: string): string {
|
|
|
299
362
|
return `SHA256:${Buffer.from(hex, "hex").toString("base64").replace(/=+$/, "")}`;
|
|
300
363
|
}
|
|
301
364
|
|
|
302
|
-
function
|
|
303
|
-
const truncation = truncateTail(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
304
|
-
const content = truncation.content || "Remote command completed.";
|
|
305
|
-
if (!truncation.truncated) return { text: content, content, truncation };
|
|
306
|
-
|
|
365
|
+
function createOutputFile(): { path: string; fd: number } {
|
|
307
366
|
const outputDir = mkdtempSync(join(tmpdir(), "pi-ssh-remote-output-"));
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
367
|
+
const path = join(outputDir, "output.log");
|
|
368
|
+
return { path, fd: openSync(path, "w", 0o600) };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function saveOutput(output: string): string {
|
|
372
|
+
const file = createOutputFile();
|
|
373
|
+
try { writeSync(file.fd, output); }
|
|
374
|
+
finally { closeSync(file.fd); }
|
|
375
|
+
return file.path;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
class RemoteExecAccumulator {
|
|
379
|
+
private chunks: Buffer[] = [];
|
|
380
|
+
private tail = Buffer.alloc(0);
|
|
381
|
+
private stderrTail = Buffer.alloc(0);
|
|
382
|
+
private outputFile: { path: string; fd: number } | undefined;
|
|
383
|
+
private totalBytes = 0;
|
|
384
|
+
private newlineCount = 0;
|
|
385
|
+
private lastByte: number | undefined;
|
|
386
|
+
|
|
387
|
+
constructor(private maxLines: number, private maxBytes: number) {}
|
|
388
|
+
|
|
389
|
+
append(chunk: Buffer): void {
|
|
390
|
+
if (!chunk.length) return;
|
|
391
|
+
this.totalBytes += chunk.length;
|
|
392
|
+
for (const byte of chunk) if (byte === 10) this.newlineCount++;
|
|
393
|
+
this.lastByte = chunk[chunk.length - 1];
|
|
394
|
+
|
|
395
|
+
if (!this.outputFile && this.totalBytes > this.maxBytes) {
|
|
396
|
+
this.outputFile = createOutputFile();
|
|
397
|
+
for (const previous of this.chunks) writeSync(this.outputFile.fd, previous);
|
|
398
|
+
this.chunks = [];
|
|
399
|
+
}
|
|
400
|
+
if (this.outputFile) writeSync(this.outputFile.fd, chunk);
|
|
401
|
+
else this.chunks.push(chunk);
|
|
402
|
+
|
|
403
|
+
this.tail = Buffer.concat([this.tail, chunk]);
|
|
404
|
+
const tailBytes = this.maxBytes + OUTPUT_FOOTER_RESERVE_BYTES + 4096;
|
|
405
|
+
if (this.tail.length > tailBytes) this.tail = this.tail.subarray(this.tail.length - tailBytes);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
appendStderr(chunk: Buffer): void {
|
|
409
|
+
this.stderrTail = Buffer.concat([this.stderrTail, chunk]);
|
|
410
|
+
if (this.stderrTail.length > this.maxBytes) this.stderrTail = this.stderrTail.subarray(this.stderrTail.length - this.maxBytes);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private ensureOutputFile(): string {
|
|
414
|
+
if (!this.outputFile) {
|
|
415
|
+
this.outputFile = createOutputFile();
|
|
416
|
+
for (const chunk of this.chunks) writeSync(this.outputFile.fd, chunk);
|
|
417
|
+
this.chunks = [];
|
|
418
|
+
}
|
|
419
|
+
return this.outputFile.path;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
finish() {
|
|
423
|
+
const totalLines = this.totalBytes ? this.newlineCount + (this.lastByte === 10 ? 0 : 1) : 0;
|
|
424
|
+
const source = this.outputFile ? this.tail.toString("utf8") : Buffer.concat(this.chunks).toString("utf8");
|
|
425
|
+
const contentBudget = Math.max(1, this.maxBytes - OUTPUT_FOOTER_RESERVE_BYTES);
|
|
426
|
+
const base = truncateTail(source, { maxLines: this.maxLines, maxBytes: contentBudget });
|
|
427
|
+
const truncated = this.totalBytes > contentBudget || totalLines > this.maxLines || base.truncated;
|
|
428
|
+
const truncation = {
|
|
429
|
+
...base,
|
|
430
|
+
truncated,
|
|
431
|
+
totalLines,
|
|
432
|
+
totalBytes: this.totalBytes,
|
|
433
|
+
maxLines: this.maxLines,
|
|
434
|
+
maxBytes: this.maxBytes,
|
|
435
|
+
};
|
|
436
|
+
const content = base.content || "Remote command completed.";
|
|
437
|
+
let fullOutputPath: string | undefined;
|
|
438
|
+
let text = content;
|
|
439
|
+
if (truncated) {
|
|
440
|
+
fullOutputPath = this.ensureOutputFile();
|
|
441
|
+
const startLine = Math.max(1, totalLines - base.outputLines + 1);
|
|
442
|
+
text += `\n\n[Showing lines ${startLine}-${totalLines} of ${totalLines} (${formatSize(this.maxBytes)} model-output limit). Full output: ${fullOutputPath}]`;
|
|
443
|
+
}
|
|
444
|
+
this.close();
|
|
445
|
+
return { text, content, truncation, fullOutputPath };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
errorMessage(): string {
|
|
449
|
+
return this.stderrTail.toString("utf8").trim();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
close(): void {
|
|
453
|
+
if (!this.outputFile) return;
|
|
454
|
+
const file = this.outputFile;
|
|
455
|
+
this.outputFile = undefined;
|
|
456
|
+
closeSync(file.fd);
|
|
457
|
+
}
|
|
314
458
|
}
|
|
315
459
|
|
|
316
460
|
function previewRemoteOutput(output: string, displayLines: number): string {
|
|
@@ -429,6 +573,53 @@ function execRemote(
|
|
|
429
573
|
});
|
|
430
574
|
}
|
|
431
575
|
|
|
576
|
+
function execRemoteLimited(
|
|
577
|
+
client: Client,
|
|
578
|
+
command: string,
|
|
579
|
+
timeoutSeconds: number,
|
|
580
|
+
maxLines: number,
|
|
581
|
+
maxBytes: number,
|
|
582
|
+
): Promise<ReturnType<RemoteExecAccumulator["finish"]>> {
|
|
583
|
+
const resolvedTimeout = parseRemoteTimeout(timeoutSeconds);
|
|
584
|
+
return new Promise((resolve, reject) => {
|
|
585
|
+
client.exec(withRemoteTimeout(command, resolvedTimeout), (error, stream) => {
|
|
586
|
+
if (error) return reject(error);
|
|
587
|
+
const accumulator = new RemoteExecAccumulator(maxLines, maxBytes);
|
|
588
|
+
let locallyTimedOut = false;
|
|
589
|
+
let settled = false;
|
|
590
|
+
const timer = setTimeout(() => {
|
|
591
|
+
locallyTimedOut = true;
|
|
592
|
+
stream.close();
|
|
593
|
+
}, (resolvedTimeout + 8) * 1000);
|
|
594
|
+
const cleanup = () => clearTimeout(timer);
|
|
595
|
+
const fail = (failure: Error) => {
|
|
596
|
+
if (settled) return;
|
|
597
|
+
settled = true;
|
|
598
|
+
cleanup();
|
|
599
|
+
accumulator.close();
|
|
600
|
+
reject(failure);
|
|
601
|
+
};
|
|
602
|
+
stream.on("data", (chunk: Buffer) => accumulator.append(chunk));
|
|
603
|
+
stream.stderr.on("data", (chunk: Buffer) => accumulator.appendStderr(chunk));
|
|
604
|
+
stream.once("error", (streamError: Error) => fail(streamError));
|
|
605
|
+
stream.on("close", (code: number | null) => {
|
|
606
|
+
if (settled) return;
|
|
607
|
+
cleanup();
|
|
608
|
+
if (locallyTimedOut || code === 124 || code === 137) {
|
|
609
|
+
fail(new Error(`Remote command timed out after ${resolvedTimeout} seconds`));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (code !== 0) {
|
|
613
|
+
fail(new Error(accumulator.errorMessage() || `Remote command exited with code ${code}`));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
settled = true;
|
|
617
|
+
resolve(accumulator.finish());
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
432
623
|
function getSftp(client: Client): Promise<SFTPWrapper> {
|
|
433
624
|
return new Promise((resolve, reject) => client.sftp((error, sftp) => error ? reject(error) : resolve(sftp)));
|
|
434
625
|
}
|
|
@@ -478,8 +669,12 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
478
669
|
let currentCtx: any;
|
|
479
670
|
let reconnectPromise: Promise<RemoteState> | null = null;
|
|
480
671
|
const forwardServers = new Map<number, Server>();
|
|
672
|
+
const forwardSpecs = new Map<number, ForwardSpec>();
|
|
673
|
+
let sessionReady = false;
|
|
674
|
+
let restoringSessionState = false;
|
|
481
675
|
let lastConnectionError: string | undefined;
|
|
482
676
|
let lastCommand = credentialCache.resume?.command ?? activeSshCommand() ?? "";
|
|
677
|
+
let turnOutputBytes = 0;
|
|
483
678
|
|
|
484
679
|
const configuredCwd = (command: string): string =>
|
|
485
680
|
endpointConfig(loadRemoteConfig(), command).remoteCwd || FALLBACK_REMOTE_CWD;
|
|
@@ -495,6 +690,98 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
495
690
|
return path;
|
|
496
691
|
};
|
|
497
692
|
|
|
693
|
+
const remotePath = (path: string): string => {
|
|
694
|
+
const normalized = path.replace(/^@/, "");
|
|
695
|
+
if (normalized.startsWith("/")) return mapPath(normalized);
|
|
696
|
+
if (!remote) return normalized;
|
|
697
|
+
return posix.join(remote.cwd, normalized);
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const serializeForward = (spec: ForwardSpec): string => `${spec.localPort}:${spec.remoteHost}:${spec.remotePort}`;
|
|
701
|
+
|
|
702
|
+
const currentSessionRemoteState = (): SessionRemoteState => remote ? {
|
|
703
|
+
version: 1,
|
|
704
|
+
connected: true,
|
|
705
|
+
command: remote.command,
|
|
706
|
+
cwd: remote.cwd,
|
|
707
|
+
routeRemoteTools,
|
|
708
|
+
forwards: [...forwardSpecs.values()].map(serializeForward),
|
|
709
|
+
} : { version: 1, connected: false };
|
|
710
|
+
|
|
711
|
+
const persistSessionRemoteState = (): void => {
|
|
712
|
+
if (!sessionReady || restoringSessionState) return;
|
|
713
|
+
pi.appendEntry(SESSION_STATE_ENTRY_TYPE, currentSessionRemoteState());
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
const loadSessionRemoteState = (ctx: any): SessionRemoteState | undefined => {
|
|
717
|
+
const branch = ctx.sessionManager.getBranch();
|
|
718
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
719
|
+
const entry = branch[index] as any;
|
|
720
|
+
if (entry.type !== "custom" || entry.customType !== SESSION_STATE_ENTRY_TYPE) continue;
|
|
721
|
+
const data = entry.data as Partial<SessionRemoteState> | undefined;
|
|
722
|
+
if (!data || data.version !== 1 || typeof data.connected !== "boolean") return undefined;
|
|
723
|
+
if (data.connected && (typeof data.command !== "string" || typeof data.cwd !== "string")) return undefined;
|
|
724
|
+
return {
|
|
725
|
+
version: 1,
|
|
726
|
+
connected: data.connected,
|
|
727
|
+
...(data.command ? { command: data.command } : {}),
|
|
728
|
+
...(data.cwd ? { cwd: data.cwd } : {}),
|
|
729
|
+
...(typeof data.routeRemoteTools === "boolean" ? { routeRemoteTools: data.routeRemoteTools } : {}),
|
|
730
|
+
...(Array.isArray(data.forwards) ? { forwards: data.forwards.filter((value): value is string => typeof value === "string") } : {}),
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
return undefined;
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
const limitRemoteToolResult = (result: any, kind: "read" | "exec", startLine = 1, requestedMaxLines?: number) => {
|
|
737
|
+
const limits = configuredOutputLimits();
|
|
738
|
+
const configuredMaxBytes = kind === "read" ? limits.readMaxBytes : limits.execMaxBytes;
|
|
739
|
+
const configuredMaxLines = kind === "read" ? limits.readMaxLines : limits.execMaxLines;
|
|
740
|
+
const maxLines = Math.min(configuredMaxLines, requestedMaxLines ?? configuredMaxLines);
|
|
741
|
+
const remaining = Math.max(0, limits.turnMaxBytes - turnOutputBytes);
|
|
742
|
+
if (remaining < MIN_MODEL_OUTPUT_BYTES) {
|
|
743
|
+
const text = `[Remote tool output omitted because this turn has used its ${formatSize(limits.turnMaxBytes)} model-output budget. Run a narrower follow-up command.]`;
|
|
744
|
+
turnOutputBytes += Buffer.byteLength(text, "utf8");
|
|
745
|
+
return { ...result, content: [{ type: "text", text }], details: { ...(result.details ?? {}), turnBudgetExceeded: true } };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const maxBytes = Math.min(configuredMaxBytes, remaining);
|
|
749
|
+
const textIndex = result.content?.findIndex((item: any) => item.type === "text") ?? -1;
|
|
750
|
+
if (textIndex < 0) return result;
|
|
751
|
+
const original = result.content[textIndex].text ?? "";
|
|
752
|
+
const reserveBytes = result.details?.modelLimited ? 0 : OUTPUT_FOOTER_RESERVE_BYTES;
|
|
753
|
+
const contentBudget = Math.max(1, maxBytes - reserveBytes);
|
|
754
|
+
const truncation = kind === "read"
|
|
755
|
+
? truncateHead(original, { maxLines, maxBytes: contentBudget })
|
|
756
|
+
: truncateTail(original, { maxLines, maxBytes: contentBudget });
|
|
757
|
+
let text = truncation.content;
|
|
758
|
+
let fullOutputPath = result.details?.fullOutputPath as string | undefined;
|
|
759
|
+
if (truncation.firstLineExceedsLimit) {
|
|
760
|
+
text = `[Line ${startLine} exceeds the ${formatSize(maxBytes)} remote read limit. Use bash with sed/head -c to inspect a bounded fragment.]`;
|
|
761
|
+
} else if (truncation.truncated) {
|
|
762
|
+
if (kind === "read") {
|
|
763
|
+
const nextOffset = startLine + truncation.outputLines;
|
|
764
|
+
text += `\n\n[Showing ${truncation.outputLines} lines (${formatSize(maxBytes)} remote read limit). Use offset=${nextOffset} to continue.]`;
|
|
765
|
+
} else {
|
|
766
|
+
fullOutputPath ??= saveOutput(original);
|
|
767
|
+
text += `\n\n[Showing the last ${truncation.outputLines} lines (${formatSize(maxBytes)} remote exec limit). Full output: ${fullOutputPath}]`;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const content = [...result.content];
|
|
771
|
+
content[textIndex] = { ...content[textIndex], text: text || (kind === "exec" ? "Remote command completed." : "") };
|
|
772
|
+
const actualBytes = Buffer.byteLength(content[textIndex].text, "utf8");
|
|
773
|
+
turnOutputBytes += actualBytes;
|
|
774
|
+
return {
|
|
775
|
+
...result,
|
|
776
|
+
content,
|
|
777
|
+
details: {
|
|
778
|
+
...(result.details ?? {}),
|
|
779
|
+
...(truncation.truncated ? { truncation } : {}),
|
|
780
|
+
...(fullOutputPath ? { fullOutputPath } : {}),
|
|
781
|
+
},
|
|
782
|
+
};
|
|
783
|
+
};
|
|
784
|
+
|
|
498
785
|
const status = (ctx: any) => {
|
|
499
786
|
currentCtx = ctx;
|
|
500
787
|
if (!remote) ctx.ui.setStatus("ssh-remote", undefined);
|
|
@@ -534,6 +821,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
534
821
|
|
|
535
822
|
async function reconnectRemote(): Promise<RemoteState> {
|
|
536
823
|
if (reconnectPromise) return reconnectPromise;
|
|
824
|
+
const resumeRouting = remote ? routeRemoteTools : (credentialCache.resume?.routeRemoteTools ?? true);
|
|
537
825
|
const source = remote ?? (credentialCache.resume ? { ...parseSshCommand(credentialCache.resume.command), cwd: credentialCache.resume.cwd } : null);
|
|
538
826
|
if (!source) throw new Error("No SSH remote connection is available to reconnect");
|
|
539
827
|
const parsed = parseSshCommand(source.command);
|
|
@@ -542,7 +830,8 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
542
830
|
const oldClient = remote?.client;
|
|
543
831
|
const next = await establish(parsed, password, source.cwd);
|
|
544
832
|
remote = next;
|
|
545
|
-
|
|
833
|
+
routeRemoteTools = resumeRouting;
|
|
834
|
+
credentialCache.resume = { command: parsed.command, cwd: next.cwd, routeRemoteTools, forwards: credentialCache.resume?.forwards };
|
|
546
835
|
oldClient?.end();
|
|
547
836
|
if (currentCtx) {
|
|
548
837
|
status(currentCtx);
|
|
@@ -572,9 +861,10 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
572
861
|
`cd -- ${quote(remote!.cwd)} && ${targetCommand} && pwd -P`,
|
|
573
862
|
))).toString().trim();
|
|
574
863
|
remote.cwd = resolved;
|
|
575
|
-
credentialCache.resume = { command: remote.command, cwd: resolved };
|
|
864
|
+
credentialCache.resume = { command: remote.command, cwd: resolved, routeRemoteTools, forwards: [...forwardSpecs.values()].map(serializeForward) };
|
|
576
865
|
saveEndpointConfig(remote.command, { remoteCwd: resolved }, true);
|
|
577
866
|
status(ctx);
|
|
867
|
+
persistSessionRemoteState();
|
|
578
868
|
return resolved;
|
|
579
869
|
};
|
|
580
870
|
|
|
@@ -636,11 +926,12 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
636
926
|
routeRemoteTools = true;
|
|
637
927
|
previous?.end();
|
|
638
928
|
if (password) setCachedPassword(parsed, password);
|
|
639
|
-
credentialCache.resume = { command, cwd: next.cwd };
|
|
929
|
+
credentialCache.resume = { command, cwd: next.cwd, routeRemoteTools, forwards: [] };
|
|
640
930
|
lastCommand = command;
|
|
641
931
|
lastConnectionError = undefined;
|
|
642
932
|
saveEndpointConfig(command, { remoteCwd: next.cwd }, true);
|
|
643
933
|
status(ctx);
|
|
934
|
+
persistSessionRemoteState();
|
|
644
935
|
ctx.ui.notify(`SSH remote connected: ${endpointDisplayLabel(next)}:${next.cwd}`, "info");
|
|
645
936
|
return next;
|
|
646
937
|
} catch (error) {
|
|
@@ -684,14 +975,32 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
684
975
|
});
|
|
685
976
|
});
|
|
686
977
|
forwardServers.set(spec.localPort, server);
|
|
978
|
+
forwardSpecs.set(spec.localPort, spec);
|
|
687
979
|
};
|
|
688
980
|
|
|
689
981
|
const stopForwards = async (): Promise<void> => {
|
|
690
982
|
const servers = [...forwardServers.values()];
|
|
691
983
|
forwardServers.clear();
|
|
984
|
+
forwardSpecs.clear();
|
|
692
985
|
await Promise.all(servers.map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
|
693
986
|
};
|
|
694
987
|
|
|
988
|
+
const restoreSessionRemoteState = async (saved: SessionRemoteState, ctx: any): Promise<void> => {
|
|
989
|
+
if (!saved.connected) {
|
|
990
|
+
credentialCache.resume = undefined;
|
|
991
|
+
status(ctx);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
const command = saved.command!;
|
|
995
|
+
const next = await connectInteractive(command, ctx, saved.cwd);
|
|
996
|
+
if (!next) throw new Error(lastConnectionError || "SSH remote session restore was cancelled or failed");
|
|
997
|
+
routeRemoteTools = saved.routeRemoteTools ?? true;
|
|
998
|
+
const specs = (saved.forwards ?? []).map(parseForwardSpec);
|
|
999
|
+
for (const spec of specs) await startForward(spec);
|
|
1000
|
+
credentialCache.resume = { command, cwd: next.cwd, routeRemoteTools, forwards: [...forwardSpecs.values()].map(serializeForward) };
|
|
1001
|
+
status(ctx);
|
|
1002
|
+
};
|
|
1003
|
+
|
|
695
1004
|
const disconnect = (ctx: any, forgetPassword = false) => {
|
|
696
1005
|
const previous = remote;
|
|
697
1006
|
remote = null;
|
|
@@ -710,22 +1019,59 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
710
1019
|
}
|
|
711
1020
|
previous?.client.end();
|
|
712
1021
|
status(ctx);
|
|
1022
|
+
persistSessionRemoteState();
|
|
713
1023
|
ctx.ui.notify(forgetPassword ? "SSH remote disconnected and cached password cleared" : "SSH remote mode disabled (password remains cached in memory only)", "info");
|
|
714
1024
|
};
|
|
715
1025
|
|
|
1026
|
+
const detectRemoteMimeType = async (path: string): Promise<string | undefined> => {
|
|
1027
|
+
try {
|
|
1028
|
+
return (await withReconnect((client) => execRemote(client, `file --mime-type -b -- ${quote(remotePath(path))}`))).toString().trim() || undefined;
|
|
1029
|
+
} catch { return undefined; }
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
const isTextMimeType = (mime: string): boolean =>
|
|
1033
|
+
mime.startsWith("text/") || /\/(?:json|ld\+json|xml|javascript|x-sh|x-shellscript|x-empty)$/.test(mime);
|
|
1034
|
+
|
|
716
1035
|
const remoteReadOps = (): ReadOperations => ({
|
|
717
1036
|
readFile: (path) => withReconnect((client) => withSftp(client, (sftp) =>
|
|
718
1037
|
new Promise<Buffer>((resolve, reject) => sftp.readFile(mapPath(path), (error, data) => error ? reject(error) : resolve(data))))),
|
|
719
1038
|
access: (path) => withReconnect((client) => withSftp(client, (sftp) =>
|
|
720
1039
|
new Promise<void>((resolve, reject) => sftp.stat(mapPath(path), (error) => error ? reject(error) : resolve())))),
|
|
721
1040
|
detectImageMimeType: async (path) => {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
return ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(mime) ? mime : null;
|
|
725
|
-
} catch { return null; }
|
|
1041
|
+
const mime = await detectRemoteMimeType(path);
|
|
1042
|
+
return mime && ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(mime) ? mime : null;
|
|
726
1043
|
},
|
|
727
1044
|
});
|
|
728
1045
|
|
|
1046
|
+
const executeRemoteRead = async (id: string, params: any, signal: AbortSignal | undefined, update: any) => {
|
|
1047
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
1048
|
+
const path = remotePath(params.path);
|
|
1049
|
+
await withReconnect((client) => withSftp(client, (sftp) =>
|
|
1050
|
+
new Promise<void>((resolve, reject) => sftp.stat(path, (error) => error ? reject(error) : resolve()))));
|
|
1051
|
+
const mime = await detectRemoteMimeType(path);
|
|
1052
|
+
if (mime && ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(mime)) {
|
|
1053
|
+
const result = await createReadTool(localCwd, { operations: remoteReadOps() }).execute(id, params, signal, update);
|
|
1054
|
+
return limitRemoteToolResult(result, "read", params.offset ?? 1);
|
|
1055
|
+
}
|
|
1056
|
+
if (mime && !isTextMimeType(mime)) throw new Error(`Refusing to read remote binary file as text (${mime}): ${params.path}`);
|
|
1057
|
+
|
|
1058
|
+
const limits = configuredOutputLimits();
|
|
1059
|
+
const startLine = Math.max(1, Math.floor(Number(params.offset ?? 1)));
|
|
1060
|
+
const requestedLines = Math.max(1, Math.floor(Number(params.limit ?? limits.readMaxLines)));
|
|
1061
|
+
const linesToFetch = Math.min(requestedLines, limits.readMaxLines) + 1;
|
|
1062
|
+
const endLine = startLine + linesToFetch - 1;
|
|
1063
|
+
const transferBytes = Math.min(DEFAULT_MAX_BYTES, limits.readMaxBytes + OUTPUT_FOOTER_RESERVE_BYTES + 4096);
|
|
1064
|
+
const command = `sed -n '${startLine},${endLine}p' ${quote(path)} | head -c ${transferBytes}`;
|
|
1065
|
+
const output = await withReconnect((client) => execRemote(client, command, false, DEFAULT_REMOTE_TIMEOUT_SECONDS));
|
|
1066
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
1067
|
+
if (!output.length && startLine > 1) throw new Error(`Offset ${startLine} is beyond end of remote file`);
|
|
1068
|
+
const result = {
|
|
1069
|
+
content: [{ type: "text", text: output.toString("utf8") }],
|
|
1070
|
+
details: { remoteRange: { path, startLine, requestedLines: Math.min(requestedLines, limits.readMaxLines) } },
|
|
1071
|
+
};
|
|
1072
|
+
return limitRemoteToolResult(result, "read", startLine, Math.min(requestedLines, limits.readMaxLines));
|
|
1073
|
+
};
|
|
1074
|
+
|
|
729
1075
|
const remoteWriteOps = (): WriteOperations => ({
|
|
730
1076
|
writeFile: (path, content) => withReconnect((client) => withSftp(client, (sftp) =>
|
|
731
1077
|
new Promise<void>((resolve, reject) => sftp.writeFile(mapPath(path), content, (error) => error ? reject(error) : resolve())))),
|
|
@@ -769,20 +1115,37 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
769
1115
|
const localWrite = createWriteTool(localCwd);
|
|
770
1116
|
const localEdit = createEditTool(localCwd);
|
|
771
1117
|
const localBash = createBashTool(localCwd);
|
|
772
|
-
pi.registerTool({
|
|
1118
|
+
pi.registerTool({
|
|
1119
|
+
...localRead,
|
|
1120
|
+
description: `Read file contents. Remote text reads fetch only the requested range and return at most ${DEFAULT_READ_MAX_LINES} lines or ${formatSize(DEFAULT_READ_MAX_BYTES)} by default; use offset/limit to continue.`,
|
|
1121
|
+
promptGuidelines: ["Use read with offset/limit for remote code and logs; inspect large files in focused chunks instead of reading them wholesale."],
|
|
1122
|
+
execute: (id, params, signal, update) => remote && routeRemoteTools
|
|
1123
|
+
? executeRemoteRead(id, params, signal, update)
|
|
1124
|
+
: localRead.execute(id, params, signal, update),
|
|
1125
|
+
});
|
|
773
1126
|
pi.registerTool({ ...localWrite, execute: (id, params, signal, update) => remote && routeRemoteTools ? createWriteTool(localCwd, { operations: remoteWriteOps() }).execute(id, params, signal, update) : localWrite.execute(id, params, signal, update) });
|
|
774
1127
|
pi.registerTool({ ...localEdit, execute: (id, params, signal, update) => remote && routeRemoteTools ? createEditTool(localCwd, { operations: remoteEditOps() }).execute(id, params, signal, update) : localEdit.execute(id, params, signal, update) });
|
|
775
|
-
pi.registerTool({
|
|
1128
|
+
pi.registerTool({
|
|
1129
|
+
...localBash,
|
|
1130
|
+
description: `Execute a shell command. Remote model-facing output returns at most the last ${DEFAULT_EXEC_MAX_LINES} lines or ${formatSize(DEFAULT_EXEC_MAX_BYTES)} by default; complete oversized output is saved locally.`,
|
|
1131
|
+
promptGuidelines: ["When using bash for remote logs and broad searches, use bounded commands such as tail, sed, or rg with limits instead of cat or unbounded find output."],
|
|
1132
|
+
execute: async (id, params, signal, update) => {
|
|
1133
|
+
if (!remote || !routeRemoteTools) return localBash.execute(id, params, signal, update);
|
|
1134
|
+
const result = await createBashTool(localCwd, { operations: remoteBashOps() }).execute(id, params, signal, update);
|
|
1135
|
+
return limitRemoteToolResult(result, "exec");
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
776
1138
|
|
|
777
1139
|
pi.registerTool({
|
|
778
1140
|
name: "ssh_remote_control",
|
|
779
1141
|
label: "SSH Remote Control",
|
|
780
|
-
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
|
|
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.",
|
|
781
1143
|
promptSnippet: "Control the configured remote SSH connection, endpoint note and memory, working directory, and local port forwarding",
|
|
782
1144
|
promptGuidelines: [
|
|
783
1145
|
"Use ssh_remote_control when the user asks the agent to enter, reconnect, inspect, or leave a remote SSH environment.",
|
|
784
1146
|
"Use ssh_remote_control 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.",
|
|
785
1147
|
`Always set timeout for ssh_remote_control remote exec commands; it defaults to ${DEFAULT_REMOTE_TIMEOUT_SECONDS} seconds when omitted.`,
|
|
1148
|
+
"Keep ssh_remote_control exec output narrow with tail, sed, rg limits, or similarly bounded commands; never cat large logs or emit broad file listings.",
|
|
786
1149
|
"Use ssh_remote_control with action disconnect after remote work when the user asks to return to the local environment.",
|
|
787
1150
|
],
|
|
788
1151
|
parameters: Type.Object({
|
|
@@ -794,7 +1157,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
794
1157
|
forwards: Type.Optional(Type.String({ description: "Space-separated LOCAL_PORT:REMOTE_HOST:REMOTE_PORT mappings; defaults to ssh-remote-config.json" })),
|
|
795
1158
|
remoteCommand: Type.Optional(Type.String({ description: "Remote shell command for the exec action" })),
|
|
796
1159
|
timeout: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_REMOTE_TIMEOUT_SECONDS, description: `Remote command timeout in seconds; defaults to ${DEFAULT_REMOTE_TIMEOUT_SECONDS}` })),
|
|
797
|
-
displayLines: Type.Optional(Type.Integer({ minimum: 1, maximum:
|
|
1160
|
+
displayLines: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_DISPLAY_LINES, description: "Collapsed visual lines for exec output; defaults to the /remote config display-lines setting (5 initially), maximum 50" })),
|
|
798
1161
|
}),
|
|
799
1162
|
async execute(_id, params, _signal, _update, ctx) {
|
|
800
1163
|
if (params.action === "status") {
|
|
@@ -813,6 +1176,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
813
1176
|
}
|
|
814
1177
|
if (params.action === "unforward") {
|
|
815
1178
|
await stopForwards();
|
|
1179
|
+
persistSessionRemoteState();
|
|
816
1180
|
return { content: [{ type: "text", text: "Closed all extension-managed SSH port forwards." }], details: { forwardedPorts: [] } };
|
|
817
1181
|
}
|
|
818
1182
|
if (params.action === "forward") {
|
|
@@ -822,7 +1186,9 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
822
1186
|
const specs = values.map(parseForwardSpec);
|
|
823
1187
|
for (const spec of specs) await startForward(spec);
|
|
824
1188
|
routeRemoteTools = false;
|
|
1189
|
+
credentialCache.resume = { command: state.command, cwd: state.cwd, routeRemoteTools, forwards: [...forwardSpecs.values()].map(serializeForward) };
|
|
825
1190
|
if (currentCtx) status(currentCtx);
|
|
1191
|
+
persistSessionRemoteState();
|
|
826
1192
|
const ports = specs.map((spec) => spec.localPort);
|
|
827
1193
|
return { content: [{ type: "text", text: `Forwarded local ports: ${ports.join(", ")}; tools remain local.` }], details: { toolRouting: "local", forwardedPorts: ports } };
|
|
828
1194
|
}
|
|
@@ -862,14 +1228,15 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
862
1228
|
}
|
|
863
1229
|
const displayLines = parseDisplayLines(params.displayLines ?? configuredDisplayLines());
|
|
864
1230
|
const timeoutSeconds = parseRemoteTimeout(params.timeout ?? DEFAULT_REMOTE_TIMEOUT_SECONDS);
|
|
865
|
-
const
|
|
1231
|
+
const limits = configuredOutputLimits();
|
|
1232
|
+
const formatted = await withReconnect((client) => execRemoteLimited(
|
|
866
1233
|
client,
|
|
867
1234
|
`cd -- ${quote(params.cwd ?? state.cwd)} && ${params.remoteCommand}`,
|
|
868
|
-
false,
|
|
869
1235
|
timeoutSeconds,
|
|
1236
|
+
limits.execMaxLines,
|
|
1237
|
+
limits.execMaxBytes,
|
|
870
1238
|
));
|
|
871
|
-
|
|
872
|
-
return {
|
|
1239
|
+
return limitRemoteToolResult({
|
|
873
1240
|
content: [{ type: "text", text: formatted.text }],
|
|
874
1241
|
details: {
|
|
875
1242
|
action: "exec",
|
|
@@ -877,10 +1244,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
877
1244
|
cwd: state.cwd,
|
|
878
1245
|
displayLines,
|
|
879
1246
|
output: formatted.content,
|
|
1247
|
+
modelLimited: true,
|
|
880
1248
|
truncation: formatted.truncation.truncated ? formatted.truncation : undefined,
|
|
881
1249
|
fullOutputPath: formatted.fullOutputPath,
|
|
882
1250
|
},
|
|
883
|
-
};
|
|
1251
|
+
}, "exec");
|
|
884
1252
|
}
|
|
885
1253
|
const command = params.command || lastCommand || activeSshCommand();
|
|
886
1254
|
if (!command) throw new Error(`No SSH endpoint configured. Set ${REMOTE_CONFIG_FILE} or pass command.`);
|
|
@@ -894,7 +1262,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
894
1262
|
});
|
|
895
1263
|
|
|
896
1264
|
pi.registerCommand("remote", {
|
|
897
|
-
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 | forward [MAPPINGS] | unforward | exec [--timeout SECONDS] [--lines N] COMMAND | cd PATH | status | reload | off | forget",
|
|
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",
|
|
898
1266
|
handler: async (args, ctx) => {
|
|
899
1267
|
const input = args.trim().replace(/^\/?remote(?:\s+|$)/i, "").trim();
|
|
900
1268
|
const action = input.toLowerCase();
|
|
@@ -904,7 +1272,8 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
904
1272
|
const active = key === config.activeEndpoint ? "*" : " ";
|
|
905
1273
|
return `${active} ${key}\n note: ${endpoint.note || "none"}\n memory: ${endpoint.memory || "none"}\n SSH: ${endpoint.sshCommand}\n cwd: ${endpoint.remoteCwd || FALLBACK_REMOTE_CWD}\n forward: ${endpoint.forwards?.join(", ") || "none"}`;
|
|
906
1274
|
});
|
|
907
|
-
|
|
1275
|
+
const limits = configuredOutputLimits(config);
|
|
1276
|
+
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");
|
|
908
1277
|
return;
|
|
909
1278
|
}
|
|
910
1279
|
if (/^ssh\s+/i.test(input)) {
|
|
@@ -937,6 +1306,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
937
1306
|
previous.client.end();
|
|
938
1307
|
await stopForwards();
|
|
939
1308
|
status(ctx);
|
|
1309
|
+
persistSessionRemoteState();
|
|
940
1310
|
}
|
|
941
1311
|
saveRemoteConfig({ ...config, activeEndpoint: key });
|
|
942
1312
|
lastCommand = command;
|
|
@@ -972,6 +1342,22 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
972
1342
|
} catch (error) { ctx.ui.notify((error as Error).message, "error"); }
|
|
973
1343
|
return;
|
|
974
1344
|
}
|
|
1345
|
+
const outputConfig = input.match(/^config\s+(read-max-lines|read-max-bytes|exec-max-lines|exec-max-bytes|turn-max-bytes)\s+(\S+)$/i);
|
|
1346
|
+
if (outputConfig) {
|
|
1347
|
+
try {
|
|
1348
|
+
const key = outputConfig[1]!.toLowerCase();
|
|
1349
|
+
const value = outputConfig[2]!;
|
|
1350
|
+
const config = loadRemoteConfig();
|
|
1351
|
+
if (key === "read-max-lines") config.readMaxLines = parseOutputLines(value, "Read max lines");
|
|
1352
|
+
else if (key === "read-max-bytes") config.readMaxBytes = parseOutputBytes(value, "Read max bytes");
|
|
1353
|
+
else if (key === "exec-max-lines") config.execMaxLines = parseOutputLines(value, "Exec max lines");
|
|
1354
|
+
else if (key === "exec-max-bytes") config.execMaxBytes = parseOutputBytes(value, "Exec max bytes");
|
|
1355
|
+
else config.turnMaxBytes = parseOutputBytes(value, "Turn max bytes", DEFAULT_MAX_BYTES * 4);
|
|
1356
|
+
saveRemoteConfig(config);
|
|
1357
|
+
ctx.ui.notify(`SSH remote output limit updated: ${key}=${value}`, "info");
|
|
1358
|
+
} catch (error) { ctx.ui.notify((error as Error).message, "error"); }
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
975
1361
|
if (/^config\s+cwd\s+/i.test(input)) {
|
|
976
1362
|
const remoteCwd = input.replace(/^config\s+cwd\s+/i, "").trim();
|
|
977
1363
|
const command = lastCommand || activeSshCommand();
|
|
@@ -999,13 +1385,16 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
999
1385
|
const specs = values.map(parseForwardSpec);
|
|
1000
1386
|
for (const spec of specs) await startForward(spec);
|
|
1001
1387
|
routeRemoteTools = false;
|
|
1388
|
+
credentialCache.resume = { command: state.command, cwd: state.cwd, routeRemoteTools, forwards: [...forwardSpecs.values()].map(serializeForward) };
|
|
1002
1389
|
status(ctx);
|
|
1390
|
+
persistSessionRemoteState();
|
|
1003
1391
|
ctx.ui.notify(`SSH remote port forwarding started; tools remain local in ${localCwd}: ${specs.map((spec) => `127.0.0.1:${spec.localPort}`).join(", ")}`, "info");
|
|
1004
1392
|
} catch (error) { ctx.ui.notify(`SSH remote port forwarding failed: ${(error as Error).message}`, "error"); }
|
|
1005
1393
|
return;
|
|
1006
1394
|
}
|
|
1007
1395
|
if (action === "unforward") {
|
|
1008
1396
|
await stopForwards();
|
|
1397
|
+
persistSessionRemoteState();
|
|
1009
1398
|
ctx.ui.notify("Closed all extension-managed SSH remote port forwards", "info");
|
|
1010
1399
|
return;
|
|
1011
1400
|
}
|
|
@@ -1022,13 +1411,14 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1022
1411
|
else timeoutSeconds = parseRemoteTimeout(option[2]);
|
|
1023
1412
|
execInput = option[3]!;
|
|
1024
1413
|
}
|
|
1025
|
-
const
|
|
1414
|
+
const limits = configuredOutputLimits();
|
|
1415
|
+
const formatted = await withReconnect((client) => execRemoteLimited(
|
|
1026
1416
|
client,
|
|
1027
1417
|
`cd -- ${quote(state.cwd)} && ${execInput}`,
|
|
1028
|
-
false,
|
|
1029
1418
|
timeoutSeconds,
|
|
1030
|
-
|
|
1031
|
-
|
|
1419
|
+
limits.execMaxLines,
|
|
1420
|
+
limits.execMaxBytes,
|
|
1421
|
+
));
|
|
1032
1422
|
const preview = previewRemoteOutput(formatted.content, displayLines);
|
|
1033
1423
|
const omitted = formatted.truncation.totalLines > displayLines
|
|
1034
1424
|
? `\n\n[Showing last ${Math.min(displayLines, formatted.truncation.totalLines)} of ${formatted.truncation.totalLines} lines]`
|
|
@@ -1064,21 +1454,53 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1064
1454
|
},
|
|
1065
1455
|
});
|
|
1066
1456
|
|
|
1457
|
+
pi.on("turn_start", () => { turnOutputBytes = 0; });
|
|
1067
1458
|
pi.on("session_start", async (event, ctx) => {
|
|
1068
1459
|
currentCtx = ctx;
|
|
1460
|
+
sessionReady = true;
|
|
1069
1461
|
status(ctx);
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1462
|
+
const saved = loadSessionRemoteState(ctx);
|
|
1463
|
+
const canInheritCurrent = event.reason === "reload" || event.reason === "new" || event.reason === "fork";
|
|
1464
|
+
const inherited = !saved && canInheritCurrent && credentialCache.resume ? credentialCache.resume : undefined;
|
|
1465
|
+
const target: SessionRemoteState | undefined = saved ?? (inherited ? {
|
|
1466
|
+
version: 1,
|
|
1467
|
+
connected: true,
|
|
1468
|
+
command: inherited.command,
|
|
1469
|
+
cwd: inherited.cwd,
|
|
1470
|
+
routeRemoteTools: inherited.routeRemoteTools,
|
|
1471
|
+
forwards: inherited.forwards ?? [],
|
|
1472
|
+
} : undefined);
|
|
1473
|
+
if (!target) {
|
|
1474
|
+
credentialCache.resume = undefined;
|
|
1475
|
+
return;
|
|
1073
1476
|
}
|
|
1477
|
+
restoringSessionState = true;
|
|
1478
|
+
try {
|
|
1479
|
+
await restoreSessionRemoteState(target, ctx);
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
ctx.ui.notify(`SSH remote session restore failed: ${(error as Error).message}`, "error");
|
|
1482
|
+
} finally {
|
|
1483
|
+
restoringSessionState = false;
|
|
1484
|
+
}
|
|
1485
|
+
if (inherited && remote) persistSessionRemoteState();
|
|
1074
1486
|
});
|
|
1075
|
-
pi.on("session_shutdown", (event) => {
|
|
1487
|
+
pi.on("session_shutdown", async (event) => {
|
|
1488
|
+
sessionReady = false;
|
|
1076
1489
|
const previous = remote;
|
|
1490
|
+
const preserveConnection = event.reason === "reload" || event.reason === "new" || event.reason === "fork";
|
|
1491
|
+
if (previous && preserveConnection) {
|
|
1492
|
+
credentialCache.resume = {
|
|
1493
|
+
command: previous.command,
|
|
1494
|
+
cwd: previous.cwd,
|
|
1495
|
+
routeRemoteTools,
|
|
1496
|
+
forwards: [...forwardSpecs.values()].map(serializeForward),
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1077
1499
|
remote = null;
|
|
1078
1500
|
routeRemoteTools = false;
|
|
1079
|
-
|
|
1501
|
+
await stopForwards();
|
|
1080
1502
|
previous?.client.end();
|
|
1081
|
-
if (
|
|
1503
|
+
if (!preserveConnection) credentialCache.resume = undefined;
|
|
1082
1504
|
});
|
|
1083
1505
|
pi.on("user_bash", async (event, ctx) => {
|
|
1084
1506
|
if (!remote || !routeRemoteTools) return undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ssh-remote",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Persistent remote SSH workspaces for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Yutong Bian",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"index.ts",
|
|
16
16
|
"README.md",
|
|
17
17
|
"README.zh-CN.md",
|
|
18
|
+
"CHANGELOG.md",
|
|
18
19
|
"LICENSE"
|
|
19
20
|
],
|
|
20
21
|
"engines": {
|