dsh-remote 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/README.zh.md +5 -5
- package/lib/index.js +557 -557
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ The harness Web UI intentionally binds `127.0.0.1` (the CLI rejects `--host 0.0.
|
|
|
10
10
|
|
|
11
11
|
- **Connect** a remote host with **password or SSH key** (SSH/SFTP via `ssh2`).
|
|
12
12
|
- **Pick a remote workspace** — a remote directory the active session treats as its project root.
|
|
13
|
-
- **Model tools** — `
|
|
13
|
+
- **Model tools** — `rw_info`, `rw_connect`, `rw_pick_workspace`, `rw_list_dir`, `rw_read_file`, `rw_exec`.
|
|
14
14
|
- **Settings → 远程工作区** — enter host/login, connect, browse the remote filesystem, set the workspace, all from the UI.
|
|
15
15
|
- The current remote workspace (`user@host:/path`) is injected into every system prompt so the agent knows the working root.
|
|
16
16
|
|
|
@@ -46,16 +46,16 @@ If `host` is empty the plugin starts disconnected and you connect at runtime.
|
|
|
46
46
|
### 2. Connect + pick a workspace
|
|
47
47
|
|
|
48
48
|
- **From the UI**: Settings → 远程工作区 → enter host/port/user + (password or key path) → **连接远程** → type a remote path and **设为远程工作区** (or **列目录** to browse).
|
|
49
|
-
- **From the agent**: ask it to `
|
|
49
|
+
- **From the agent**: ask it to `rw_connect(host)`, then `rw_pick_workspace(path=/…/project)`. You can also use `/remote` to see the current status.
|
|
50
50
|
|
|
51
51
|
### 3. Work in the remote workspace
|
|
52
52
|
|
|
53
53
|
The agent uses:
|
|
54
54
|
|
|
55
55
|
```
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
rw_list_dir(path?) # list a remote dir (defaults to the workspace)
|
|
57
|
+
rw_read_file(path=-…) # read a remote file (paged)
|
|
58
|
+
rw_exec(command=…) # run any shell command on the remote
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
Because the workspace path is in the system prompt, the agent treats it as the working root and combines these tools to inspect/build/test the remote project.
|
package/README.zh.md
CHANGED
|
@@ -10,7 +10,7 @@ DSH Web 界面刻意只监听 `127.0.0.1`(`--host 0.0.0.0` 被安全地拒绝
|
|
|
10
10
|
|
|
11
11
|
- **连接远程**:支持**密码或 SSH key**(基于 `ssh2`)。
|
|
12
12
|
- **选取远程工作区**:把远程某个目录当作本会话的项目根。
|
|
13
|
-
- **模型工具**:`
|
|
13
|
+
- **模型工具**:`rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_read_file`、`rw_exec`。
|
|
14
14
|
- **设置 → 远程工作区**:输入主机与登录方式→连接→浏览远程文件系统→设为工作区。
|
|
15
15
|
- 当前远程工作区(`user@host:/path`)会注入每次系统提示,让 Agent 明确知道工作根目录。
|
|
16
16
|
|
|
@@ -46,15 +46,15 @@ dsh plugin --profile web add dsh-remote
|
|
|
46
46
|
### 2. 连接并选工作区
|
|
47
47
|
|
|
48
48
|
- **UI**:设置 → 远程工作区 → 填 host/port/user +(密码或 key 路径)→ **连接远程** → 输入远程路径并按 **设为远程工作区**(或用 **列目录** 浏览)。
|
|
49
|
-
- **让 Agent 做**:`
|
|
49
|
+
- **让 Agent 做**:`rw_connect(host)` 之后 `rw_pick_workspace(path=/…/project)`;随时可用 `/remote` 看状态。
|
|
50
50
|
|
|
51
51
|
### 3. 在远程工作区里工作
|
|
52
52
|
|
|
53
53
|
Agent 使用:
|
|
54
54
|
```
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
rw_list_dir(path?)
|
|
56
|
+
rw_read_file(path=…)
|
|
57
|
+
rw_exec(command=…)
|
|
58
58
|
```
|
|
59
59
|
由于工作区路径在系统提示里,Agent 会把它当作工作根,组合这些工具对远程项目做查看/构建/测试。
|
|
60
60
|
|
package/lib/index.js
CHANGED
|
@@ -1,558 +1,558 @@
|
|
|
1
|
-
// dsh-remote — remote-work assistant for DeepSeek Harness.
|
|
2
|
-
//
|
|
3
|
-
// Host half. Turns "give me a remote host + login" into a usable REMOTE WORKSPACE:
|
|
4
|
-
// • one persistent SSH/SFTP pool per configured remote (password OR private key),
|
|
5
|
-
// • a "current remote workspace" — a remote directory the agent treats as the
|
|
6
|
-
// active project root (user@host:/path) — injected into every system prompt,
|
|
7
|
-
// • model tools `
|
|
8
|
-
// `
|
|
9
|
-
// • JSON endpoints the client settings page uses to connect → browse → select the
|
|
10
|
-
// remote workspace over the harness `webServer`.
|
|
11
|
-
//
|
|
12
|
-
// The engine (path guard + shell quoting + ssh pool + exec) reuses the foundation
|
|
13
|
-
// proven by dsh-remote-debug, extended with password auth and a mutable workspace:
|
|
14
|
-
// `ctx.fs` / the local workspace registry stay untouched — this is a REMOTE workspace
|
|
15
|
-
// presented as such to the model and UI, not a replacement of the local one.
|
|
16
|
-
//
|
|
17
|
-
// Plugin Config MUST be a schemastery schema (zod rejects the undefined row config).
|
|
18
|
-
import z from '@deepseek-ai/schemastery'
|
|
19
|
-
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
|
-
import ssh2 from 'ssh2'
|
|
21
|
-
import { readFileSync } from 'node:fs'
|
|
22
|
-
import { homedir } from 'node:os'
|
|
23
|
-
import path from 'node:path'
|
|
24
|
-
|
|
25
|
-
const { Client } = ssh2
|
|
26
|
-
|
|
27
|
-
export const name = 'dsh-remote'
|
|
28
|
-
|
|
29
|
-
// tools (model tools) + systemPrompt (per-turn workspace hint) are required.
|
|
30
|
-
// webServer is read lazily via ctx.get so it is NOT in inject.
|
|
31
|
-
export const inject = ['tools', 'systemPrompt']
|
|
32
|
-
|
|
33
|
-
export const Config = z.object({
|
|
34
|
-
/** Remote SSH host (empty → the plugin starts disconnected). */
|
|
35
|
-
host: z.string().default(''),
|
|
36
|
-
/** Remote SSH port (22 unless the machine uses a custom port). */
|
|
37
|
-
port: z.number().step(1).min(1).max(65535).default(22),
|
|
38
|
-
/** SSH login user. */
|
|
39
|
-
username: z.string().default(''),
|
|
40
|
-
/** Password login (only when the remote has no key. Override the fallback below). */
|
|
41
|
-
password: z.string().default(''),
|
|
42
|
-
/** Absolute private-key path; empty → ~/.ssh/id_rsa. */
|
|
43
|
-
privateKeyPath: z.string().default(''),
|
|
44
|
-
/** Key passphrase when the key is encrypted. */
|
|
45
|
-
passphrase: z.string().default(''),
|
|
46
|
-
/** Initial remote workspace path (absolute dir the agent should treat as root). */
|
|
47
|
-
workspace: z.string().default(''),
|
|
48
|
-
/** Per-command timeout. */
|
|
49
|
-
commandTimeoutMs: z.number().step(1).min(1000).default(20000),
|
|
50
|
-
/** SSH connection establishment timeout. */
|
|
51
|
-
connectTimeoutMs: z.number().step(1).min(1000).default(15000),
|
|
52
|
-
/** Hard ceiling on collected remote output per call. */
|
|
53
|
-
maxOutputChars: z.number().step(1).min(1024).default(200000),
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
// ── shell / path helpers (proven in the read tooling) ───────────────────────
|
|
57
|
-
|
|
58
|
-
/** Single-quote one shell argument verbatim. */
|
|
59
|
-
function shq(s) {
|
|
60
|
-
return "'" + String(s).replace(/'/g, "'\\''") + "'"
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Collapse `.`/`..`/duplicate slashes into a clean absolute remote path. */
|
|
64
|
-
function normalizeRemotePath(p) {
|
|
65
|
-
const parts = []
|
|
66
|
-
for (const seg of String(p).split('/')) {
|
|
67
|
-
if (seg === '' || seg === '.') continue
|
|
68
|
-
if (seg === '..') {
|
|
69
|
-
parts.pop()
|
|
70
|
-
continue
|
|
71
|
-
}
|
|
72
|
-
parts.push(seg)
|
|
73
|
-
}
|
|
74
|
-
return '/' + parts.join('/')
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Parent dir of a remote absolute path (string-level). */
|
|
78
|
-
function remoteDirname(p) {
|
|
79
|
-
const norm = normalizeRemotePath(p)
|
|
80
|
-
if (norm === '/') return '/'
|
|
81
|
-
const idx = norm.lastIndexOf('/')
|
|
82
|
-
return idx <= 0 ? '/' : norm.slice(0, idx)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function truncate(s, max) {
|
|
86
|
-
if (s.length <= max) return s
|
|
87
|
-
return s.slice(0, max) + `\n…[truncated: ${s.length - max} more chars]`
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ── SSH pool (key OR password) ──────────────────────────────────────────────
|
|
91
|
-
|
|
92
|
-
class SshPool {
|
|
93
|
-
constructor(config) {
|
|
94
|
-
this.config = config
|
|
95
|
-
this.client = null
|
|
96
|
-
this.connecting = null
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
resolveKeyPath() {
|
|
100
|
-
const p = this.config.privateKeyPath
|
|
101
|
-
if (!p) return path.join(homedir(), '.ssh', 'id_rsa')
|
|
102
|
-
if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
|
|
103
|
-
return p
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Configure (and reconnect with) a new target. Returns this pool for chaining. */
|
|
107
|
-
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace }) {
|
|
108
|
-
if (host !== undefined) this.config.host = String(host)
|
|
109
|
-
if (port !== undefined && Number(port)) this.config.port = Number(port)
|
|
110
|
-
if (username !== undefined) this.config.username = String(username)
|
|
111
|
-
if (password !== undefined && password !== null) this.config.password = String(password)
|
|
112
|
-
if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
|
|
113
|
-
if (passphrase !== undefined) this.config.passphrase = String(passphrase)
|
|
114
|
-
if (workspace !== undefined) this.config.workspace = String(workspace)
|
|
115
|
-
this.close()
|
|
116
|
-
return this
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
connect() {
|
|
120
|
-
if (this.client) return Promise.resolve(this.client)
|
|
121
|
-
if (this.connecting) return this.connecting
|
|
122
|
-
this.connecting = this._doConnect().finally(() => {
|
|
123
|
-
this.connecting = null
|
|
124
|
-
})
|
|
125
|
-
return this.connecting
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
_doConnect() {
|
|
129
|
-
return new Promise((resolve, reject) => {
|
|
130
|
-
const client = new Client()
|
|
131
|
-
let settled = false
|
|
132
|
-
const fail = (err) => {
|
|
133
|
-
if (settled) return
|
|
134
|
-
settled = true
|
|
135
|
-
if (this.client === client) this.client = null
|
|
136
|
-
reject(err)
|
|
137
|
-
}
|
|
138
|
-
client.on('ready', () => {
|
|
139
|
-
if (settled) return
|
|
140
|
-
settled = true
|
|
141
|
-
this.client = client
|
|
142
|
-
resolve(client)
|
|
143
|
-
})
|
|
144
|
-
client.on('error', fail)
|
|
145
|
-
client.on('close', () => {
|
|
146
|
-
if (this.client === client) this.client = null
|
|
147
|
-
fail(new Error('ssh connection closed'))
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
const opts = {
|
|
151
|
-
host: this.config.host,
|
|
152
|
-
port: this.config.port,
|
|
153
|
-
username: this.config.username,
|
|
154
|
-
readyTimeout: this.config.connectTimeoutMs,
|
|
155
|
-
keepaliveInterval: 15000,
|
|
156
|
-
keepaliveCountMax: 3,
|
|
157
|
-
}
|
|
158
|
-
if (this.config.password) {
|
|
159
|
-
opts.password = this.config.password
|
|
160
|
-
} else {
|
|
161
|
-
let key
|
|
162
|
-
try {
|
|
163
|
-
key = readFileSync(this.resolveKeyPath())
|
|
164
|
-
} catch (err) {
|
|
165
|
-
return fail(
|
|
166
|
-
new Error(`cannot read private key "${this.resolveKeyPath()}": ${err && err.message}`),
|
|
167
|
-
)
|
|
168
|
-
}
|
|
169
|
-
opts.privateKey = key
|
|
170
|
-
opts.passphrase = this.config.passphrase || undefined
|
|
171
|
-
}
|
|
172
|
-
client.connect(opts)
|
|
173
|
-
})
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** Run one remote command; resolves { code, signal, stdout, stderr }. */
|
|
177
|
-
exec(command, timeoutMs) {
|
|
178
|
-
return this.connect().then(
|
|
179
|
-
(client) =>
|
|
180
|
-
new Promise((resolve, reject) => {
|
|
181
|
-
client.exec(command, (err, stream) => {
|
|
182
|
-
if (err) return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
|
|
183
|
-
let stdout = ''
|
|
184
|
-
let stderr = ''
|
|
185
|
-
let settled = false
|
|
186
|
-
let exitCode = null
|
|
187
|
-
let exitSignal = null
|
|
188
|
-
const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
|
|
189
|
-
const settle = () => {
|
|
190
|
-
if (settled) return
|
|
191
|
-
settled = true
|
|
192
|
-
clearTimeout(timer)
|
|
193
|
-
resolve({
|
|
194
|
-
code: exitCode,
|
|
195
|
-
signal: exitSignal,
|
|
196
|
-
stdout: truncate(stdout, this.config.maxOutputChars),
|
|
197
|
-
stderr: truncate(stderr, this.config.maxOutputChars),
|
|
198
|
-
})
|
|
199
|
-
}
|
|
200
|
-
const timer = setTimeout(() => {
|
|
201
|
-
if (settled) return
|
|
202
|
-
exitCode = -1
|
|
203
|
-
exitSignal = 'TIMEOUT'
|
|
204
|
-
try {
|
|
205
|
-
stream.close()
|
|
206
|
-
} catch {}
|
|
207
|
-
settle()
|
|
208
|
-
}, timeoutMs || this.config.commandTimeoutMs)
|
|
209
|
-
stream.on('close', (code, signal) => {
|
|
210
|
-
if (settled) return
|
|
211
|
-
exitCode = code
|
|
212
|
-
exitSignal = signal
|
|
213
|
-
settle()
|
|
214
|
-
})
|
|
215
|
-
stream.on('data', (d) => {
|
|
216
|
-
if (stdout.length < hardCap) stdout += d
|
|
217
|
-
})
|
|
218
|
-
stream.stderr.on('data', (d) => {
|
|
219
|
-
if (stderr.length < hardCap) stderr += d
|
|
220
|
-
})
|
|
221
|
-
stream.on('error', (e) => {
|
|
222
|
-
if (settled) return
|
|
223
|
-
settled = true
|
|
224
|
-
clearTimeout(timer)
|
|
225
|
-
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
226
|
-
})
|
|
227
|
-
})
|
|
228
|
-
}),
|
|
229
|
-
)
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
close() {
|
|
233
|
-
const client = this.client
|
|
234
|
-
this.client = null
|
|
235
|
-
if (client) {
|
|
236
|
-
try {
|
|
237
|
-
client.end()
|
|
238
|
-
} catch {}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// ── helper: run + text ─────────────────────────────────────────────────────
|
|
244
|
-
|
|
245
|
-
export async function apply(ctx, config) {
|
|
246
|
-
const pool = new SshPool(config)
|
|
247
|
-
ctx.effect(() => () => pool.close(), 'dsh-remote.close')
|
|
248
|
-
|
|
249
|
-
const run = async (cmd, opts = {}) => {
|
|
250
|
-
const res = await pool.exec(cmd, opts.timeoutMs)
|
|
251
|
-
const parts = []
|
|
252
|
-
if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
|
|
253
|
-
if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
|
|
254
|
-
if (!parts.length) parts.push('(no output)')
|
|
255
|
-
let text = parts.join('\n')
|
|
256
|
-
if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${opts.timeoutMs ?? config.commandTimeoutMs}ms]`
|
|
257
|
-
else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
|
|
258
|
-
return text
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// ── remote workspace state ────────────────────────────────────────────────
|
|
262
|
-
|
|
263
|
-
const wsPath = () => (config.workspace || '').trim()
|
|
264
|
-
const status = () => ({
|
|
265
|
-
host: config.host,
|
|
266
|
-
port: config.port,
|
|
267
|
-
username: config.username,
|
|
268
|
-
connected: !!pool.client,
|
|
269
|
-
workspace: wsPath(),
|
|
270
|
-
})
|
|
271
|
-
|
|
272
|
-
// ── tools ─────────────────────────────────────────────────────────────────
|
|
273
|
-
|
|
274
|
-
const renderErr = (err) => ({
|
|
275
|
-
kind: 'error',
|
|
276
|
-
text: String((err && err.message) || err),
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
const tools = [
|
|
280
|
-
defineTool({
|
|
281
|
-
name: '
|
|
282
|
-
description:
|
|
283
|
-
'Show the remote environment: host/user/port, connection health, and the current remote workspace path. Call this first to orient, or when a remote_* call fails to check connectivity.',
|
|
284
|
-
parameters: {},
|
|
285
|
-
output: {
|
|
286
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
287
|
-
render: (_a, v) => [{ type: 'text', text: v.text }],
|
|
288
|
-
},
|
|
289
|
-
async execute() {
|
|
290
|
-
const s = status()
|
|
291
|
-
const lines = [
|
|
292
|
-
`Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}`,
|
|
293
|
-
`Current remote workspace: ${s.workspace || '(none — call
|
|
294
|
-
`Connected: ${s.connected ? 'yes' : 'no'}`,
|
|
295
|
-
'',
|
|
296
|
-
]
|
|
297
|
-
if (s.host && s.workspace) {
|
|
298
|
-
try {
|
|
299
|
-
const res = await pool.exec('echo ok; hostname; pwd', Math.min(config.commandTimeoutMs, 8000))
|
|
300
|
-
if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
|
|
301
|
-
else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
|
|
302
|
-
else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
|
|
303
|
-
} catch (err) {
|
|
304
|
-
lines.push('Ping: FAILED — ' + ((err && err.message) || err))
|
|
305
|
-
}
|
|
306
|
-
} else {
|
|
307
|
-
lines.push('No host + workspace configured — call
|
|
308
|
-
}
|
|
309
|
-
return { text: lines.join('\n') }
|
|
310
|
-
},
|
|
311
|
-
}),
|
|
312
|
-
|
|
313
|
-
defineTool({
|
|
314
|
-
name: '
|
|
315
|
-
description:
|
|
316
|
-
'Connect SSH to a remote host for remote workspace work. Provide host (required), user, optional password or privateKeyPath/port. Once connected, call
|
|
317
|
-
parameters: {
|
|
318
|
-
host: { type: 'string', required: true, description: 'Remote host IP or hostname' },
|
|
319
|
-
username: { type: 'string', description: 'SSH user (default from config or root)' },
|
|
320
|
-
port: { type: 'integer', description: 'SSH port (default 22)' },
|
|
321
|
-
password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
|
|
322
|
-
privateKeyPath: { type: 'string', description: 'Absolute private-key path' },
|
|
323
|
-
},
|
|
324
|
-
output: {
|
|
325
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
326
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
327
|
-
},
|
|
328
|
-
async execute(args) {
|
|
329
|
-
const host = String(args.host || '').trim()
|
|
330
|
-
if (!host) throw new Error('
|
|
331
|
-
pool.setTarget({
|
|
332
|
-
host,
|
|
333
|
-
username: args.username || config.username || 'root',
|
|
334
|
-
port: args.port || undefined,
|
|
335
|
-
password: args.password !== undefined ? args.password : undefined,
|
|
336
|
-
privateKeyPath: args.privateKeyPath || undefined,
|
|
337
|
-
})
|
|
338
|
-
try {
|
|
339
|
-
const res = await pool.exec('echo ok; hostname', 8000)
|
|
340
|
-
if (res.code !== 0 && !res.stdout) return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
|
|
341
|
-
return { text: `Connected to ${host} as ${config.username}.\nhostname: ${res.stdout.replace(/\s+/g, ' ').trim()}\n\npick a workspace with
|
|
342
|
-
} catch (err) {
|
|
343
|
-
throw err
|
|
344
|
-
}
|
|
345
|
-
},
|
|
346
|
-
}),
|
|
347
|
-
|
|
348
|
-
defineTool({
|
|
349
|
-
name: '
|
|
350
|
-
description:
|
|
351
|
-
'Set the remote workspace directory this session should treat as its working root on the connected remote. Verifies it exists (a directory). Use
|
|
352
|
-
parameters: {
|
|
353
|
-
path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project' },
|
|
354
|
-
},
|
|
355
|
-
output: {
|
|
356
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
357
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
358
|
-
},
|
|
359
|
-
async execute(args) {
|
|
360
|
-
const p = normalizeRemotePath(String(args.path || ''))
|
|
361
|
-
if (!p || p === '/') throw new Error('
|
|
362
|
-
const res = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
|
|
363
|
-
const ok = res.stdout.trim() === 'DIR'
|
|
364
|
-
if (!ok) return { text: `not a directory (or missing) on ${p}` }
|
|
365
|
-
config.workspace = p
|
|
366
|
-
return { text: `Remote workspace set to ${p} on ${config.username}@${config.host}.` }
|
|
367
|
-
},
|
|
368
|
-
}),
|
|
369
|
-
|
|
370
|
-
defineTool({
|
|
371
|
-
name: '
|
|
372
|
-
description:
|
|
373
|
-
'List a remote directory (or a single file) via SSH. Path is absolute; if omitted, lists the current remote workspace.',
|
|
374
|
-
parameters: {
|
|
375
|
-
path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
|
|
376
|
-
},
|
|
377
|
-
output: {
|
|
378
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
379
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
380
|
-
},
|
|
381
|
-
async execute(args) {
|
|
382
|
-
const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
|
|
383
|
-
if (!p) throw new Error('
|
|
384
|
-
return { text: await run(`ls -la --color=never ${shq(p)}`) }
|
|
385
|
-
},
|
|
386
|
-
}),
|
|
387
|
-
|
|
388
|
-
defineTool({
|
|
389
|
-
name: '
|
|
390
|
-
description:
|
|
391
|
-
'Read a text file on the remote host with line numbers. Supports paging with startLine/endLine. Path is absolute.',
|
|
392
|
-
parameters: {
|
|
393
|
-
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
394
|
-
startLine: { type: 'integer', description: '1-based first line (default 1)' },
|
|
395
|
-
endLine: { type: 'integer', description: '1-based last line (inclusive)' },
|
|
396
|
-
maxLines: { type: 'integer', description: 'Max lines (default 2000)' },
|
|
397
|
-
},
|
|
398
|
-
output: {
|
|
399
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
400
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
401
|
-
},
|
|
402
|
-
async execute(args) {
|
|
403
|
-
const p = normalizeRemotePath(String(args.path || ''))
|
|
404
|
-
if (!p) throw new Error('
|
|
405
|
-
const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
|
|
406
|
-
let from = Math.max(Number(args.startLine) || 1, 1)
|
|
407
|
-
let to = Number(args.endLine) || 0
|
|
408
|
-
if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
|
|
409
|
-
const raw = await run(`sed -n '${from},${to}p' -- ${shq(p)}`, { timeoutMs: config.commandTimeoutMs })
|
|
410
|
-
const numbered = raw.split('\n').map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
|
|
411
|
-
let text = numbered === '' ? '(empty or out of range)' : numbered
|
|
412
|
-
if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
|
|
413
|
-
return { text }
|
|
414
|
-
},
|
|
415
|
-
}),
|
|
416
|
-
|
|
417
|
-
defineTool({
|
|
418
|
-
name: '
|
|
419
|
-
description:
|
|
420
|
-
'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped.',
|
|
421
|
-
parameters: {
|
|
422
|
-
command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
|
|
423
|
-
},
|
|
424
|
-
output: {
|
|
425
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
426
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
427
|
-
},
|
|
428
|
-
async execute(args) {
|
|
429
|
-
const cmd = String(args.command || '')
|
|
430
|
-
if (!cmd) throw new Error('
|
|
431
|
-
return { text: await run(cmd, { timeoutMs: config.commandTimeoutMs }) }
|
|
432
|
-
},
|
|
433
|
-
}),
|
|
434
|
-
]
|
|
435
|
-
|
|
436
|
-
for (const t of tools) {
|
|
437
|
-
ctx.tools.register(t)
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// ── system-prompt injection: the current remote workspace ─────────────────
|
|
441
|
-
ctx.systemPrompt.section({
|
|
442
|
-
name: 'dsh-remote',
|
|
443
|
-
order: 88,
|
|
444
|
-
text: () => {
|
|
445
|
-
const w = wsPath()
|
|
446
|
-
if (!w || !config.host) return ''
|
|
447
|
-
return (
|
|
448
|
-
'## Remote workspace\n' +
|
|
449
|
-
`Current remote workspace: ${config.username}@${config.host}:${w}\n` +
|
|
450
|
-
'Use the remote_* tools (
|
|
451
|
-
)
|
|
452
|
-
},
|
|
453
|
-
})
|
|
454
|
-
|
|
455
|
-
// ── slash command: /remote reports status + connection hints ──────────────
|
|
456
|
-
const commands = ctx.get('commands')
|
|
457
|
-
if (commands !== undefined) {
|
|
458
|
-
commands.register({
|
|
459
|
-
name: 'remote',
|
|
460
|
-
description: 'Show the current remote workspace / connection status and how to use remote tools.',
|
|
461
|
-
handler: (invocation) => {
|
|
462
|
-
const s = status()
|
|
463
|
-
return {
|
|
464
|
-
kind: 'success',
|
|
465
|
-
text:
|
|
466
|
-
`Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected})\n` +
|
|
467
|
-
`Remote workspace: ${s.workspace || '(none)'}\n` +
|
|
468
|
-
`\nUse tools:
|
|
469
|
-
(s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
|
|
470
|
-
}
|
|
471
|
-
},
|
|
472
|
-
})
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// ── JSON endpoints for settings UI ─────────────────────────────────────────
|
|
476
|
-
const webServer = ctx.get('webServer')
|
|
477
|
-
if (webServer === undefined) return
|
|
478
|
-
|
|
479
|
-
const sendJson = (res, status, body) => {
|
|
480
|
-
res.statusCode = status
|
|
481
|
-
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
482
|
-
res.end(JSON.stringify(body))
|
|
483
|
-
}
|
|
484
|
-
const readBody = (req) =>
|
|
485
|
-
new Promise((resolve) => {
|
|
486
|
-
const chunks = []
|
|
487
|
-
req.on('data', (c) => chunks.push(c))
|
|
488
|
-
req.on('end', () => resolve(chunks.join('')))
|
|
489
|
-
})
|
|
490
|
-
|
|
491
|
-
const routes = [
|
|
492
|
-
{
|
|
493
|
-
kind: 'exact',
|
|
494
|
-
path: '/dsh-remote/status',
|
|
495
|
-
handler: async (req, res) => {
|
|
496
|
-
if (req.method === 'GET') return sendJson(res, 200, status())
|
|
497
|
-
sendJson(res, 405, { error: 'method not allowed' })
|
|
498
|
-
},
|
|
499
|
-
},
|
|
500
|
-
{
|
|
501
|
-
kind: 'exact',
|
|
502
|
-
path: '/dsh-remote/connect',
|
|
503
|
-
handler: async (req, res) => {
|
|
504
|
-
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
505
|
-
try {
|
|
506
|
-
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
507
|
-
pool.setTarget({
|
|
508
|
-
host: payload.host,
|
|
509
|
-
port: payload.port,
|
|
510
|
-
username: payload.username,
|
|
511
|
-
password: payload.password !== undefined && payload.password !== '' ? payload.password : undefined,
|
|
512
|
-
privateKeyPath: payload.privateKeyPath,
|
|
513
|
-
workspace: payload.workspace,
|
|
514
|
-
})
|
|
515
|
-
await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
|
|
516
|
-
return sendJson(res, 200, { ok: true, ...status() })
|
|
517
|
-
} catch (err) {
|
|
518
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
519
|
-
}
|
|
520
|
-
},
|
|
521
|
-
},
|
|
522
|
-
{
|
|
523
|
-
kind: 'exact',
|
|
524
|
-
path: '/dsh-remote/ls',
|
|
525
|
-
handler: async (req, res) => {
|
|
526
|
-
try {
|
|
527
|
-
const m = (req.url || '').match(/path=([^&]*)/)
|
|
528
|
-
const p = m ? decodeURIComponent(m[1]) : wsPath()
|
|
529
|
-
const out = await run(`ls -la --color=never ${shq(p || '/')}`)
|
|
530
|
-
return sendJson(res, 200, { path: p, text: out })
|
|
531
|
-
} catch (err) {
|
|
532
|
-
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
533
|
-
}
|
|
534
|
-
},
|
|
535
|
-
},
|
|
536
|
-
{
|
|
537
|
-
kind: 'exact',
|
|
538
|
-
path: '/dsh-remote/workspace',
|
|
539
|
-
handler: async (req, res) => {
|
|
540
|
-
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
541
|
-
try {
|
|
542
|
-
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
543
|
-
const p = normalizeRemotePath(String(payload.path || ''))
|
|
544
|
-
if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
|
|
545
|
-
const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
|
|
546
|
-
if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
|
|
547
|
-
config.workspace = p
|
|
548
|
-
return sendJson(res, 200, { ok: true, workspace: p, ...status() })
|
|
549
|
-
} catch (err) {
|
|
550
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
551
|
-
}
|
|
552
|
-
},
|
|
553
|
-
},
|
|
554
|
-
]
|
|
555
|
-
|
|
556
|
-
const disposers = routes.map((r) => webServer.register(r))
|
|
557
|
-
ctx.effect(() => () => disposers.forEach((d) => d && d()), 'dsh-remote.routes')
|
|
1
|
+
// dsh-remote — remote-work assistant for DeepSeek Harness.
|
|
2
|
+
//
|
|
3
|
+
// Host half. Turns "give me a remote host + login" into a usable REMOTE WORKSPACE:
|
|
4
|
+
// • one persistent SSH/SFTP pool per configured remote (password OR private key),
|
|
5
|
+
// • a "current remote workspace" — a remote directory the agent treats as the
|
|
6
|
+
// active project root (user@host:/path) — injected into every system prompt,
|
|
7
|
+
// • model tools `rw_info` / `rw_connect` / `rw_pick_workspace` /
|
|
8
|
+
// `rw_list_dir` / `rw_read_file` / `rw_exec`,
|
|
9
|
+
// • JSON endpoints the client settings page uses to connect → browse → select the
|
|
10
|
+
// remote workspace over the harness `webServer`.
|
|
11
|
+
//
|
|
12
|
+
// The engine (path guard + shell quoting + ssh pool + exec) reuses the foundation
|
|
13
|
+
// proven by dsh-remote-debug, extended with password auth and a mutable workspace:
|
|
14
|
+
// `ctx.fs` / the local workspace registry stay untouched — this is a REMOTE workspace
|
|
15
|
+
// presented as such to the model and UI, not a replacement of the local one.
|
|
16
|
+
//
|
|
17
|
+
// Plugin Config MUST be a schemastery schema (zod rejects the undefined row config).
|
|
18
|
+
import z from '@deepseek-ai/schemastery'
|
|
19
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
|
+
import ssh2 from 'ssh2'
|
|
21
|
+
import { readFileSync } from 'node:fs'
|
|
22
|
+
import { homedir } from 'node:os'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
|
|
25
|
+
const { Client } = ssh2
|
|
26
|
+
|
|
27
|
+
export const name = 'dsh-remote'
|
|
28
|
+
|
|
29
|
+
// tools (model tools) + systemPrompt (per-turn workspace hint) are required.
|
|
30
|
+
// webServer is read lazily via ctx.get so it is NOT in inject.
|
|
31
|
+
export const inject = ['tools', 'systemPrompt']
|
|
32
|
+
|
|
33
|
+
export const Config = z.object({
|
|
34
|
+
/** Remote SSH host (empty → the plugin starts disconnected). */
|
|
35
|
+
host: z.string().default(''),
|
|
36
|
+
/** Remote SSH port (22 unless the machine uses a custom port). */
|
|
37
|
+
port: z.number().step(1).min(1).max(65535).default(22),
|
|
38
|
+
/** SSH login user. */
|
|
39
|
+
username: z.string().default(''),
|
|
40
|
+
/** Password login (only when the remote has no key. Override the fallback below). */
|
|
41
|
+
password: z.string().default(''),
|
|
42
|
+
/** Absolute private-key path; empty → ~/.ssh/id_rsa. */
|
|
43
|
+
privateKeyPath: z.string().default(''),
|
|
44
|
+
/** Key passphrase when the key is encrypted. */
|
|
45
|
+
passphrase: z.string().default(''),
|
|
46
|
+
/** Initial remote workspace path (absolute dir the agent should treat as root). */
|
|
47
|
+
workspace: z.string().default(''),
|
|
48
|
+
/** Per-command timeout. */
|
|
49
|
+
commandTimeoutMs: z.number().step(1).min(1000).default(20000),
|
|
50
|
+
/** SSH connection establishment timeout. */
|
|
51
|
+
connectTimeoutMs: z.number().step(1).min(1000).default(15000),
|
|
52
|
+
/** Hard ceiling on collected remote output per call. */
|
|
53
|
+
maxOutputChars: z.number().step(1).min(1024).default(200000),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// ── shell / path helpers (proven in the read tooling) ───────────────────────
|
|
57
|
+
|
|
58
|
+
/** Single-quote one shell argument verbatim. */
|
|
59
|
+
function shq(s) {
|
|
60
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Collapse `.`/`..`/duplicate slashes into a clean absolute remote path. */
|
|
64
|
+
function normalizeRemotePath(p) {
|
|
65
|
+
const parts = []
|
|
66
|
+
for (const seg of String(p).split('/')) {
|
|
67
|
+
if (seg === '' || seg === '.') continue
|
|
68
|
+
if (seg === '..') {
|
|
69
|
+
parts.pop()
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
parts.push(seg)
|
|
73
|
+
}
|
|
74
|
+
return '/' + parts.join('/')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Parent dir of a remote absolute path (string-level). */
|
|
78
|
+
function remoteDirname(p) {
|
|
79
|
+
const norm = normalizeRemotePath(p)
|
|
80
|
+
if (norm === '/') return '/'
|
|
81
|
+
const idx = norm.lastIndexOf('/')
|
|
82
|
+
return idx <= 0 ? '/' : norm.slice(0, idx)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function truncate(s, max) {
|
|
86
|
+
if (s.length <= max) return s
|
|
87
|
+
return s.slice(0, max) + `\n…[truncated: ${s.length - max} more chars]`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── SSH pool (key OR password) ──────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
class SshPool {
|
|
93
|
+
constructor(config) {
|
|
94
|
+
this.config = config
|
|
95
|
+
this.client = null
|
|
96
|
+
this.connecting = null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
resolveKeyPath() {
|
|
100
|
+
const p = this.config.privateKeyPath
|
|
101
|
+
if (!p) return path.join(homedir(), '.ssh', 'id_rsa')
|
|
102
|
+
if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
|
|
103
|
+
return p
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Configure (and reconnect with) a new target. Returns this pool for chaining. */
|
|
107
|
+
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace }) {
|
|
108
|
+
if (host !== undefined) this.config.host = String(host)
|
|
109
|
+
if (port !== undefined && Number(port)) this.config.port = Number(port)
|
|
110
|
+
if (username !== undefined) this.config.username = String(username)
|
|
111
|
+
if (password !== undefined && password !== null) this.config.password = String(password)
|
|
112
|
+
if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
|
|
113
|
+
if (passphrase !== undefined) this.config.passphrase = String(passphrase)
|
|
114
|
+
if (workspace !== undefined) this.config.workspace = String(workspace)
|
|
115
|
+
this.close()
|
|
116
|
+
return this
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
connect() {
|
|
120
|
+
if (this.client) return Promise.resolve(this.client)
|
|
121
|
+
if (this.connecting) return this.connecting
|
|
122
|
+
this.connecting = this._doConnect().finally(() => {
|
|
123
|
+
this.connecting = null
|
|
124
|
+
})
|
|
125
|
+
return this.connecting
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_doConnect() {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const client = new Client()
|
|
131
|
+
let settled = false
|
|
132
|
+
const fail = (err) => {
|
|
133
|
+
if (settled) return
|
|
134
|
+
settled = true
|
|
135
|
+
if (this.client === client) this.client = null
|
|
136
|
+
reject(err)
|
|
137
|
+
}
|
|
138
|
+
client.on('ready', () => {
|
|
139
|
+
if (settled) return
|
|
140
|
+
settled = true
|
|
141
|
+
this.client = client
|
|
142
|
+
resolve(client)
|
|
143
|
+
})
|
|
144
|
+
client.on('error', fail)
|
|
145
|
+
client.on('close', () => {
|
|
146
|
+
if (this.client === client) this.client = null
|
|
147
|
+
fail(new Error('ssh connection closed'))
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const opts = {
|
|
151
|
+
host: this.config.host,
|
|
152
|
+
port: this.config.port,
|
|
153
|
+
username: this.config.username,
|
|
154
|
+
readyTimeout: this.config.connectTimeoutMs,
|
|
155
|
+
keepaliveInterval: 15000,
|
|
156
|
+
keepaliveCountMax: 3,
|
|
157
|
+
}
|
|
158
|
+
if (this.config.password) {
|
|
159
|
+
opts.password = this.config.password
|
|
160
|
+
} else {
|
|
161
|
+
let key
|
|
162
|
+
try {
|
|
163
|
+
key = readFileSync(this.resolveKeyPath())
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return fail(
|
|
166
|
+
new Error(`cannot read private key "${this.resolveKeyPath()}": ${err && err.message}`),
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
opts.privateKey = key
|
|
170
|
+
opts.passphrase = this.config.passphrase || undefined
|
|
171
|
+
}
|
|
172
|
+
client.connect(opts)
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Run one remote command; resolves { code, signal, stdout, stderr }. */
|
|
177
|
+
exec(command, timeoutMs) {
|
|
178
|
+
return this.connect().then(
|
|
179
|
+
(client) =>
|
|
180
|
+
new Promise((resolve, reject) => {
|
|
181
|
+
client.exec(command, (err, stream) => {
|
|
182
|
+
if (err) return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
|
|
183
|
+
let stdout = ''
|
|
184
|
+
let stderr = ''
|
|
185
|
+
let settled = false
|
|
186
|
+
let exitCode = null
|
|
187
|
+
let exitSignal = null
|
|
188
|
+
const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
|
|
189
|
+
const settle = () => {
|
|
190
|
+
if (settled) return
|
|
191
|
+
settled = true
|
|
192
|
+
clearTimeout(timer)
|
|
193
|
+
resolve({
|
|
194
|
+
code: exitCode,
|
|
195
|
+
signal: exitSignal,
|
|
196
|
+
stdout: truncate(stdout, this.config.maxOutputChars),
|
|
197
|
+
stderr: truncate(stderr, this.config.maxOutputChars),
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
const timer = setTimeout(() => {
|
|
201
|
+
if (settled) return
|
|
202
|
+
exitCode = -1
|
|
203
|
+
exitSignal = 'TIMEOUT'
|
|
204
|
+
try {
|
|
205
|
+
stream.close()
|
|
206
|
+
} catch {}
|
|
207
|
+
settle()
|
|
208
|
+
}, timeoutMs || this.config.commandTimeoutMs)
|
|
209
|
+
stream.on('close', (code, signal) => {
|
|
210
|
+
if (settled) return
|
|
211
|
+
exitCode = code
|
|
212
|
+
exitSignal = signal
|
|
213
|
+
settle()
|
|
214
|
+
})
|
|
215
|
+
stream.on('data', (d) => {
|
|
216
|
+
if (stdout.length < hardCap) stdout += d
|
|
217
|
+
})
|
|
218
|
+
stream.stderr.on('data', (d) => {
|
|
219
|
+
if (stderr.length < hardCap) stderr += d
|
|
220
|
+
})
|
|
221
|
+
stream.on('error', (e) => {
|
|
222
|
+
if (settled) return
|
|
223
|
+
settled = true
|
|
224
|
+
clearTimeout(timer)
|
|
225
|
+
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
}),
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
close() {
|
|
233
|
+
const client = this.client
|
|
234
|
+
this.client = null
|
|
235
|
+
if (client) {
|
|
236
|
+
try {
|
|
237
|
+
client.end()
|
|
238
|
+
} catch {}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── helper: run + text ─────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
export async function apply(ctx, config) {
|
|
246
|
+
const pool = new SshPool(config)
|
|
247
|
+
ctx.effect(() => () => pool.close(), 'dsh-remote.close')
|
|
248
|
+
|
|
249
|
+
const run = async (cmd, opts = {}) => {
|
|
250
|
+
const res = await pool.exec(cmd, opts.timeoutMs)
|
|
251
|
+
const parts = []
|
|
252
|
+
if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
|
|
253
|
+
if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
|
|
254
|
+
if (!parts.length) parts.push('(no output)')
|
|
255
|
+
let text = parts.join('\n')
|
|
256
|
+
if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${opts.timeoutMs ?? config.commandTimeoutMs}ms]`
|
|
257
|
+
else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
|
|
258
|
+
return text
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── remote workspace state ────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
const wsPath = () => (config.workspace || '').trim()
|
|
264
|
+
const status = () => ({
|
|
265
|
+
host: config.host,
|
|
266
|
+
port: config.port,
|
|
267
|
+
username: config.username,
|
|
268
|
+
connected: !!pool.client,
|
|
269
|
+
workspace: wsPath(),
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
// ── tools ─────────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
const renderErr = (err) => ({
|
|
275
|
+
kind: 'error',
|
|
276
|
+
text: String((err && err.message) || err),
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const tools = [
|
|
280
|
+
defineTool({
|
|
281
|
+
name: 'rw_info',
|
|
282
|
+
description:
|
|
283
|
+
'Show the remote environment: host/user/port, connection health, and the current remote workspace path. Call this first to orient, or when a remote_* call fails to check connectivity.',
|
|
284
|
+
parameters: {},
|
|
285
|
+
output: {
|
|
286
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
287
|
+
render: (_a, v) => [{ type: 'text', text: v.text }],
|
|
288
|
+
},
|
|
289
|
+
async execute() {
|
|
290
|
+
const s = status()
|
|
291
|
+
const lines = [
|
|
292
|
+
`Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}`,
|
|
293
|
+
`Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
|
|
294
|
+
`Connected: ${s.connected ? 'yes' : 'no'}`,
|
|
295
|
+
'',
|
|
296
|
+
]
|
|
297
|
+
if (s.host && s.workspace) {
|
|
298
|
+
try {
|
|
299
|
+
const res = await pool.exec('echo ok; hostname; pwd', Math.min(config.commandTimeoutMs, 8000))
|
|
300
|
+
if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
|
|
301
|
+
else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
|
|
302
|
+
else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
|
|
303
|
+
} catch (err) {
|
|
304
|
+
lines.push('Ping: FAILED — ' + ((err && err.message) || err))
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
lines.push('No host + workspace configured — call rw_connect with a host to get started.')
|
|
308
|
+
}
|
|
309
|
+
return { text: lines.join('\n') }
|
|
310
|
+
},
|
|
311
|
+
}),
|
|
312
|
+
|
|
313
|
+
defineTool({
|
|
314
|
+
name: 'rw_connect',
|
|
315
|
+
description:
|
|
316
|
+
'Connect SSH to a remote host for remote workspace work. Provide host (required), user, optional password or privateKeyPath/port. Once connected, call rw_pick_workspace to pick the workspace directory this session should work in.',
|
|
317
|
+
parameters: {
|
|
318
|
+
host: { type: 'string', required: true, description: 'Remote host IP or hostname' },
|
|
319
|
+
username: { type: 'string', description: 'SSH user (default from config or root)' },
|
|
320
|
+
port: { type: 'integer', description: 'SSH port (default 22)' },
|
|
321
|
+
password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
|
|
322
|
+
privateKeyPath: { type: 'string', description: 'Absolute private-key path' },
|
|
323
|
+
},
|
|
324
|
+
output: {
|
|
325
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
326
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
327
|
+
},
|
|
328
|
+
async execute(args) {
|
|
329
|
+
const host = String(args.host || '').trim()
|
|
330
|
+
if (!host) throw new Error('rw_connect: host is required')
|
|
331
|
+
pool.setTarget({
|
|
332
|
+
host,
|
|
333
|
+
username: args.username || config.username || 'root',
|
|
334
|
+
port: args.port || undefined,
|
|
335
|
+
password: args.password !== undefined ? args.password : undefined,
|
|
336
|
+
privateKeyPath: args.privateKeyPath || undefined,
|
|
337
|
+
})
|
|
338
|
+
try {
|
|
339
|
+
const res = await pool.exec('echo ok; hostname', 8000)
|
|
340
|
+
if (res.code !== 0 && !res.stdout) return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
|
|
341
|
+
return { text: `Connected to ${host} as ${config.username}.\nhostname: ${res.stdout.replace(/\s+/g, ' ').trim()}\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
|
|
342
|
+
} catch (err) {
|
|
343
|
+
throw err
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
}),
|
|
347
|
+
|
|
348
|
+
defineTool({
|
|
349
|
+
name: 'rw_pick_workspace',
|
|
350
|
+
description:
|
|
351
|
+
'Set the remote workspace directory this session should treat as its working root on the connected remote. Verifies it exists (a directory). Use rw_list_dir to browse first if unsure.',
|
|
352
|
+
parameters: {
|
|
353
|
+
path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project' },
|
|
354
|
+
},
|
|
355
|
+
output: {
|
|
356
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
357
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
358
|
+
},
|
|
359
|
+
async execute(args) {
|
|
360
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
361
|
+
if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
|
|
362
|
+
const res = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
|
|
363
|
+
const ok = res.stdout.trim() === 'DIR'
|
|
364
|
+
if (!ok) return { text: `not a directory (or missing) on ${p}` }
|
|
365
|
+
config.workspace = p
|
|
366
|
+
return { text: `Remote workspace set to ${p} on ${config.username}@${config.host}.` }
|
|
367
|
+
},
|
|
368
|
+
}),
|
|
369
|
+
|
|
370
|
+
defineTool({
|
|
371
|
+
name: 'rw_list_dir',
|
|
372
|
+
description:
|
|
373
|
+
'List a remote directory (or a single file) via SSH. Path is absolute; if omitted, lists the current remote workspace.',
|
|
374
|
+
parameters: {
|
|
375
|
+
path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
|
|
376
|
+
},
|
|
377
|
+
output: {
|
|
378
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
379
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
380
|
+
},
|
|
381
|
+
async execute(args) {
|
|
382
|
+
const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
|
|
383
|
+
if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
|
|
384
|
+
return { text: await run(`ls -la --color=never ${shq(p)}`) }
|
|
385
|
+
},
|
|
386
|
+
}),
|
|
387
|
+
|
|
388
|
+
defineTool({
|
|
389
|
+
name: 'rw_read_file',
|
|
390
|
+
description:
|
|
391
|
+
'Read a text file on the remote host with line numbers. Supports paging with startLine/endLine. Path is absolute.',
|
|
392
|
+
parameters: {
|
|
393
|
+
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
394
|
+
startLine: { type: 'integer', description: '1-based first line (default 1)' },
|
|
395
|
+
endLine: { type: 'integer', description: '1-based last line (inclusive)' },
|
|
396
|
+
maxLines: { type: 'integer', description: 'Max lines (default 2000)' },
|
|
397
|
+
},
|
|
398
|
+
output: {
|
|
399
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
400
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
401
|
+
},
|
|
402
|
+
async execute(args) {
|
|
403
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
404
|
+
if (!p) throw new Error('rw_read_file: path is required')
|
|
405
|
+
const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
|
|
406
|
+
let from = Math.max(Number(args.startLine) || 1, 1)
|
|
407
|
+
let to = Number(args.endLine) || 0
|
|
408
|
+
if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
|
|
409
|
+
const raw = await run(`sed -n '${from},${to}p' -- ${shq(p)}`, { timeoutMs: config.commandTimeoutMs })
|
|
410
|
+
const numbered = raw.split('\n').map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
|
|
411
|
+
let text = numbered === '' ? '(empty or out of range)' : numbered
|
|
412
|
+
if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
|
|
413
|
+
return { text }
|
|
414
|
+
},
|
|
415
|
+
}),
|
|
416
|
+
|
|
417
|
+
defineTool({
|
|
418
|
+
name: 'rw_exec',
|
|
419
|
+
description:
|
|
420
|
+
'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped.',
|
|
421
|
+
parameters: {
|
|
422
|
+
command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
|
|
423
|
+
},
|
|
424
|
+
output: {
|
|
425
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
426
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
427
|
+
},
|
|
428
|
+
async execute(args) {
|
|
429
|
+
const cmd = String(args.command || '')
|
|
430
|
+
if (!cmd) throw new Error('rw_exec: command is required')
|
|
431
|
+
return { text: await run(cmd, { timeoutMs: config.commandTimeoutMs }) }
|
|
432
|
+
},
|
|
433
|
+
}),
|
|
434
|
+
]
|
|
435
|
+
|
|
436
|
+
for (const t of tools) {
|
|
437
|
+
ctx.tools.register(t)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ── system-prompt injection: the current remote workspace ─────────────────
|
|
441
|
+
ctx.systemPrompt.section({
|
|
442
|
+
name: 'dsh-remote',
|
|
443
|
+
order: 88,
|
|
444
|
+
text: () => {
|
|
445
|
+
const w = wsPath()
|
|
446
|
+
if (!w || !config.host) return ''
|
|
447
|
+
return (
|
|
448
|
+
'## Remote workspace\n' +
|
|
449
|
+
`Current remote workspace: ${config.username}@${config.host}:${w}\n` +
|
|
450
|
+
'Use the remote_* tools (rw_list_dir / rw_read_file / rw_exec) to inspect and act on files on the remote host. Treat this directory as the working root for this task.'
|
|
451
|
+
)
|
|
452
|
+
},
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
// ── slash command: /remote reports status + connection hints ──────────────
|
|
456
|
+
const commands = ctx.get('commands')
|
|
457
|
+
if (commands !== undefined) {
|
|
458
|
+
commands.register({
|
|
459
|
+
name: 'remote',
|
|
460
|
+
description: 'Show the current remote workspace / connection status and how to use remote tools.',
|
|
461
|
+
handler: (invocation) => {
|
|
462
|
+
const s = status()
|
|
463
|
+
return {
|
|
464
|
+
kind: 'success',
|
|
465
|
+
text:
|
|
466
|
+
`Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected})\n` +
|
|
467
|
+
`Remote workspace: ${s.workspace || '(none)'}\n` +
|
|
468
|
+
`\nUse tools: rw_list_dir / rw_read_file / rw_exec.` +
|
|
469
|
+
(s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ── JSON endpoints for settings UI ─────────────────────────────────────────
|
|
476
|
+
const webServer = ctx.get('webServer')
|
|
477
|
+
if (webServer === undefined) return
|
|
478
|
+
|
|
479
|
+
const sendJson = (res, status, body) => {
|
|
480
|
+
res.statusCode = status
|
|
481
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
482
|
+
res.end(JSON.stringify(body))
|
|
483
|
+
}
|
|
484
|
+
const readBody = (req) =>
|
|
485
|
+
new Promise((resolve) => {
|
|
486
|
+
const chunks = []
|
|
487
|
+
req.on('data', (c) => chunks.push(c))
|
|
488
|
+
req.on('end', () => resolve(chunks.join('')))
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
const routes = [
|
|
492
|
+
{
|
|
493
|
+
kind: 'exact',
|
|
494
|
+
path: '/dsh-remote/status',
|
|
495
|
+
handler: async (req, res) => {
|
|
496
|
+
if (req.method === 'GET') return sendJson(res, 200, status())
|
|
497
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
kind: 'exact',
|
|
502
|
+
path: '/dsh-remote/connect',
|
|
503
|
+
handler: async (req, res) => {
|
|
504
|
+
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
505
|
+
try {
|
|
506
|
+
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
507
|
+
pool.setTarget({
|
|
508
|
+
host: payload.host,
|
|
509
|
+
port: payload.port,
|
|
510
|
+
username: payload.username,
|
|
511
|
+
password: payload.password !== undefined && payload.password !== '' ? payload.password : undefined,
|
|
512
|
+
privateKeyPath: payload.privateKeyPath,
|
|
513
|
+
workspace: payload.workspace,
|
|
514
|
+
})
|
|
515
|
+
await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
|
|
516
|
+
return sendJson(res, 200, { ok: true, ...status() })
|
|
517
|
+
} catch (err) {
|
|
518
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
kind: 'exact',
|
|
524
|
+
path: '/dsh-remote/ls',
|
|
525
|
+
handler: async (req, res) => {
|
|
526
|
+
try {
|
|
527
|
+
const m = (req.url || '').match(/path=([^&]*)/)
|
|
528
|
+
const p = m ? decodeURIComponent(m[1]) : wsPath()
|
|
529
|
+
const out = await run(`ls -la --color=never ${shq(p || '/')}`)
|
|
530
|
+
return sendJson(res, 200, { path: p, text: out })
|
|
531
|
+
} catch (err) {
|
|
532
|
+
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
kind: 'exact',
|
|
538
|
+
path: '/dsh-remote/workspace',
|
|
539
|
+
handler: async (req, res) => {
|
|
540
|
+
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
541
|
+
try {
|
|
542
|
+
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
543
|
+
const p = normalizeRemotePath(String(payload.path || ''))
|
|
544
|
+
if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
|
|
545
|
+
const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
|
|
546
|
+
if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
|
|
547
|
+
config.workspace = p
|
|
548
|
+
return sendJson(res, 200, { ok: true, workspace: p, ...status() })
|
|
549
|
+
} catch (err) {
|
|
550
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
]
|
|
555
|
+
|
|
556
|
+
const disposers = routes.map((r) => webServer.register(r))
|
|
557
|
+
ctx.effect(() => () => disposers.forEach((d) => d && d()), 'dsh-remote.routes')
|
|
558
558
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password or key) to a remote host, pick a remote workspace directory, and operate on it with remote_list_dir / remote_read_file / remote_exec tools from the agent and a settings-page browser.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|