dsh-remote 0.2.0 → 0.2.2

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 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** — `remote_info`, `remote_connect`, `remote_pick_workspace`, `remote_list_dir`, `remote_read_file`, `remote_exec`.
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 `remote_connect(host)`, then `remote_pick_workspace(path=/…/project)`. You can also use `/remote` to see the current status.
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
- remote_list_dir(path?) # list a remote dir (defaults to the workspace)
57
- remote_read_file(path=-…) # read a remote file (paged)
58
- remote_exec(command=…) # run any shell command on the remote
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
- - **模型工具**:`remote_info`、`remote_connect`、`remote_pick_workspace`、`remote_list_dir`、`remote_read_file`、`remote_exec`。
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 做**:`remote_connect(host)` 之后 `remote_pick_workspace(path=/…/project)`;随时可用 `/remote` 看状态。
49
+ - **让 Agent 做**:`rw_connect(host)` 之后 `rw_pick_workspace(path=/…/project)`;随时可用 `/remote` 看状态。
50
50
 
51
51
  ### 3. 在远程工作区里工作
52
52
 
53
53
  Agent 使用:
54
54
  ```
55
- remote_list_dir(path?)
56
- remote_read_file(path=…)
57
- remote_exec(command=…)
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,560 @@
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 `remote_info` / `remote_connect` / `remote_pick_workspace` /
8
- // `remote_list_dir` / `remote_read_file` / `remote_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: 'remote_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 remote_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 remote_connect with a host to get started.')
308
- }
309
- return { text: lines.join('\n') }
310
- },
311
- }),
312
-
313
- defineTool({
314
- name: 'remote_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 remote_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('remote_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 remote_pick_workspace (path=<abs>).` }
342
- } catch (err) {
343
- throw err
344
- }
345
- },
346
- }),
347
-
348
- defineTool({
349
- name: 'remote_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 remote_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('remote_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: 'remote_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('remote_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: 'remote_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('remote_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: 'remote_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('remote_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 (remote_list_dir / remote_read_file / remote_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: remote_list_dir / remote_read_file / remote_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')
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 + systemPrompt + webServer are required. webServer is INJECTED (not just
30
+ // lazily read) so this plugin activates only after the web server is up — otherwise
31
+ // apply() runs ahead of webServer and the /dsh-remote/* JSON routes never register
32
+ // (silently: the boot shows no error, but status/ls/workspace return the SPA fallback).
33
+ export const inject = ['tools', 'systemPrompt', 'webServer']
34
+
35
+ export const Config = z.object({
36
+ /** Remote SSH host (empty → the plugin starts disconnected). */
37
+ host: z.string().default(''),
38
+ /** Remote SSH port (22 unless the machine uses a custom port). */
39
+ port: z.number().step(1).min(1).max(65535).default(22),
40
+ /** SSH login user. */
41
+ username: z.string().default(''),
42
+ /** Password login (only when the remote has no key. Override the fallback below). */
43
+ password: z.string().default(''),
44
+ /** Absolute private-key path; empty → ~/.ssh/id_rsa. */
45
+ privateKeyPath: z.string().default(''),
46
+ /** Key passphrase when the key is encrypted. */
47
+ passphrase: z.string().default(''),
48
+ /** Initial remote workspace path (absolute dir the agent should treat as root). */
49
+ workspace: z.string().default(''),
50
+ /** Per-command timeout. */
51
+ commandTimeoutMs: z.number().step(1).min(1000).default(20000),
52
+ /** SSH connection establishment timeout. */
53
+ connectTimeoutMs: z.number().step(1).min(1000).default(15000),
54
+ /** Hard ceiling on collected remote output per call. */
55
+ maxOutputChars: z.number().step(1).min(1024).default(200000),
56
+ })
57
+
58
+ // ── shell / path helpers (proven in the read tooling) ───────────────────────
59
+
60
+ /** Single-quote one shell argument verbatim. */
61
+ function shq(s) {
62
+ return "'" + String(s).replace(/'/g, "'\\''") + "'"
63
+ }
64
+
65
+ /** Collapse `.`/`..`/duplicate slashes into a clean absolute remote path. */
66
+ function normalizeRemotePath(p) {
67
+ const parts = []
68
+ for (const seg of String(p).split('/')) {
69
+ if (seg === '' || seg === '.') continue
70
+ if (seg === '..') {
71
+ parts.pop()
72
+ continue
73
+ }
74
+ parts.push(seg)
75
+ }
76
+ return '/' + parts.join('/')
77
+ }
78
+
79
+ /** Parent dir of a remote absolute path (string-level). */
80
+ function remoteDirname(p) {
81
+ const norm = normalizeRemotePath(p)
82
+ if (norm === '/') return '/'
83
+ const idx = norm.lastIndexOf('/')
84
+ return idx <= 0 ? '/' : norm.slice(0, idx)
85
+ }
86
+
87
+ function truncate(s, max) {
88
+ if (s.length <= max) return s
89
+ return s.slice(0, max) + `\n…[truncated: ${s.length - max} more chars]`
90
+ }
91
+
92
+ // ── SSH pool (key OR password) ──────────────────────────────────────────────
93
+
94
+ class SshPool {
95
+ constructor(config) {
96
+ this.config = config
97
+ this.client = null
98
+ this.connecting = null
99
+ }
100
+
101
+ resolveKeyPath() {
102
+ const p = this.config.privateKeyPath
103
+ if (!p) return path.join(homedir(), '.ssh', 'id_rsa')
104
+ if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
105
+ return p
106
+ }
107
+
108
+ /** Configure (and reconnect with) a new target. Returns this pool for chaining. */
109
+ setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace }) {
110
+ if (host !== undefined) this.config.host = String(host)
111
+ if (port !== undefined && Number(port)) this.config.port = Number(port)
112
+ if (username !== undefined) this.config.username = String(username)
113
+ if (password !== undefined && password !== null) this.config.password = String(password)
114
+ if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
115
+ if (passphrase !== undefined) this.config.passphrase = String(passphrase)
116
+ if (workspace !== undefined) this.config.workspace = String(workspace)
117
+ this.close()
118
+ return this
119
+ }
120
+
121
+ connect() {
122
+ if (this.client) return Promise.resolve(this.client)
123
+ if (this.connecting) return this.connecting
124
+ this.connecting = this._doConnect().finally(() => {
125
+ this.connecting = null
126
+ })
127
+ return this.connecting
128
+ }
129
+
130
+ _doConnect() {
131
+ return new Promise((resolve, reject) => {
132
+ const client = new Client()
133
+ let settled = false
134
+ const fail = (err) => {
135
+ if (settled) return
136
+ settled = true
137
+ if (this.client === client) this.client = null
138
+ reject(err)
139
+ }
140
+ client.on('ready', () => {
141
+ if (settled) return
142
+ settled = true
143
+ this.client = client
144
+ resolve(client)
145
+ })
146
+ client.on('error', fail)
147
+ client.on('close', () => {
148
+ if (this.client === client) this.client = null
149
+ fail(new Error('ssh connection closed'))
150
+ })
151
+
152
+ const opts = {
153
+ host: this.config.host,
154
+ port: this.config.port,
155
+ username: this.config.username,
156
+ readyTimeout: this.config.connectTimeoutMs,
157
+ keepaliveInterval: 15000,
158
+ keepaliveCountMax: 3,
159
+ }
160
+ if (this.config.password) {
161
+ opts.password = this.config.password
162
+ } else {
163
+ let key
164
+ try {
165
+ key = readFileSync(this.resolveKeyPath())
166
+ } catch (err) {
167
+ return fail(
168
+ new Error(`cannot read private key "${this.resolveKeyPath()}": ${err && err.message}`),
169
+ )
170
+ }
171
+ opts.privateKey = key
172
+ opts.passphrase = this.config.passphrase || undefined
173
+ }
174
+ client.connect(opts)
175
+ })
176
+ }
177
+
178
+ /** Run one remote command; resolves { code, signal, stdout, stderr }. */
179
+ exec(command, timeoutMs) {
180
+ return this.connect().then(
181
+ (client) =>
182
+ new Promise((resolve, reject) => {
183
+ client.exec(command, (err, stream) => {
184
+ if (err) return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
185
+ let stdout = ''
186
+ let stderr = ''
187
+ let settled = false
188
+ let exitCode = null
189
+ let exitSignal = null
190
+ const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
191
+ const settle = () => {
192
+ if (settled) return
193
+ settled = true
194
+ clearTimeout(timer)
195
+ resolve({
196
+ code: exitCode,
197
+ signal: exitSignal,
198
+ stdout: truncate(stdout, this.config.maxOutputChars),
199
+ stderr: truncate(stderr, this.config.maxOutputChars),
200
+ })
201
+ }
202
+ const timer = setTimeout(() => {
203
+ if (settled) return
204
+ exitCode = -1
205
+ exitSignal = 'TIMEOUT'
206
+ try {
207
+ stream.close()
208
+ } catch {}
209
+ settle()
210
+ }, timeoutMs || this.config.commandTimeoutMs)
211
+ stream.on('close', (code, signal) => {
212
+ if (settled) return
213
+ exitCode = code
214
+ exitSignal = signal
215
+ settle()
216
+ })
217
+ stream.on('data', (d) => {
218
+ if (stdout.length < hardCap) stdout += d
219
+ })
220
+ stream.stderr.on('data', (d) => {
221
+ if (stderr.length < hardCap) stderr += d
222
+ })
223
+ stream.on('error', (e) => {
224
+ if (settled) return
225
+ settled = true
226
+ clearTimeout(timer)
227
+ reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
228
+ })
229
+ })
230
+ }),
231
+ )
232
+ }
233
+
234
+ close() {
235
+ const client = this.client
236
+ this.client = null
237
+ if (client) {
238
+ try {
239
+ client.end()
240
+ } catch {}
241
+ }
242
+ }
243
+ }
244
+
245
+ // ── helper: run + text ─────────────────────────────────────────────────────
246
+
247
+ export async function apply(ctx, config) {
248
+ const pool = new SshPool(config)
249
+ ctx.effect(() => () => pool.close(), 'dsh-remote.close')
250
+
251
+ const run = async (cmd, opts = {}) => {
252
+ const res = await pool.exec(cmd, opts.timeoutMs)
253
+ const parts = []
254
+ if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
255
+ if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
256
+ if (!parts.length) parts.push('(no output)')
257
+ let text = parts.join('\n')
258
+ if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${opts.timeoutMs ?? config.commandTimeoutMs}ms]`
259
+ else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
260
+ return text
261
+ }
262
+
263
+ // ── remote workspace state ────────────────────────────────────────────────
264
+
265
+ const wsPath = () => (config.workspace || '').trim()
266
+ const status = () => ({
267
+ host: config.host,
268
+ port: config.port,
269
+ username: config.username,
270
+ connected: !!pool.client,
271
+ workspace: wsPath(),
272
+ })
273
+
274
+ // ── tools ─────────────────────────────────────────────────────────────────
275
+
276
+ const renderErr = (err) => ({
277
+ kind: 'error',
278
+ text: String((err && err.message) || err),
279
+ })
280
+
281
+ const tools = [
282
+ defineTool({
283
+ name: 'rw_info',
284
+ description:
285
+ '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.',
286
+ parameters: {},
287
+ output: {
288
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
289
+ render: (_a, v) => [{ type: 'text', text: v.text }],
290
+ },
291
+ async execute() {
292
+ const s = status()
293
+ const lines = [
294
+ `Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}`,
295
+ `Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
296
+ `Connected: ${s.connected ? 'yes' : 'no'}`,
297
+ '',
298
+ ]
299
+ if (s.host && s.workspace) {
300
+ try {
301
+ const res = await pool.exec('echo ok; hostname; pwd', Math.min(config.commandTimeoutMs, 8000))
302
+ if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
303
+ else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
304
+ else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
305
+ } catch (err) {
306
+ lines.push('Ping: FAILED — ' + ((err && err.message) || err))
307
+ }
308
+ } else {
309
+ lines.push('No host + workspace configured — call rw_connect with a host to get started.')
310
+ }
311
+ return { text: lines.join('\n') }
312
+ },
313
+ }),
314
+
315
+ defineTool({
316
+ name: 'rw_connect',
317
+ description:
318
+ '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.',
319
+ parameters: {
320
+ host: { type: 'string', required: true, description: 'Remote host IP or hostname' },
321
+ username: { type: 'string', description: 'SSH user (default from config or root)' },
322
+ port: { type: 'integer', description: 'SSH port (default 22)' },
323
+ password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
324
+ privateKeyPath: { type: 'string', description: 'Absolute private-key path' },
325
+ },
326
+ output: {
327
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
328
+ render: (_a, a) => [{ type: 'text', text: a.text }],
329
+ },
330
+ async execute(args) {
331
+ const host = String(args.host || '').trim()
332
+ if (!host) throw new Error('rw_connect: host is required')
333
+ pool.setTarget({
334
+ host,
335
+ username: args.username || config.username || 'root',
336
+ port: args.port || undefined,
337
+ password: args.password !== undefined ? args.password : undefined,
338
+ privateKeyPath: args.privateKeyPath || undefined,
339
+ })
340
+ try {
341
+ const res = await pool.exec('echo ok; hostname', 8000)
342
+ if (res.code !== 0 && !res.stdout) return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
343
+ 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>).` }
344
+ } catch (err) {
345
+ throw err
346
+ }
347
+ },
348
+ }),
349
+
350
+ defineTool({
351
+ name: 'rw_pick_workspace',
352
+ description:
353
+ '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.',
354
+ parameters: {
355
+ path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project' },
356
+ },
357
+ output: {
358
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
359
+ render: (_a, a) => [{ type: 'text', text: a.text }],
360
+ },
361
+ async execute(args) {
362
+ const p = normalizeRemotePath(String(args.path || ''))
363
+ if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
364
+ const res = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
365
+ const ok = res.stdout.trim() === 'DIR'
366
+ if (!ok) return { text: `not a directory (or missing) on ${p}` }
367
+ config.workspace = p
368
+ return { text: `Remote workspace set to ${p} on ${config.username}@${config.host}.` }
369
+ },
370
+ }),
371
+
372
+ defineTool({
373
+ name: 'rw_list_dir',
374
+ description:
375
+ 'List a remote directory (or a single file) via SSH. Path is absolute; if omitted, lists the current remote workspace.',
376
+ parameters: {
377
+ path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
378
+ },
379
+ output: {
380
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
381
+ render: (_a, a) => [{ type: 'text', text: a.text }],
382
+ },
383
+ async execute(args) {
384
+ const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
385
+ if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
386
+ return { text: await run(`ls -la --color=never ${shq(p)}`) }
387
+ },
388
+ }),
389
+
390
+ defineTool({
391
+ name: 'rw_read_file',
392
+ description:
393
+ 'Read a text file on the remote host with line numbers. Supports paging with startLine/endLine. Path is absolute.',
394
+ parameters: {
395
+ path: { type: 'string', required: true, description: 'Absolute remote file path' },
396
+ startLine: { type: 'integer', description: '1-based first line (default 1)' },
397
+ endLine: { type: 'integer', description: '1-based last line (inclusive)' },
398
+ maxLines: { type: 'integer', description: 'Max lines (default 2000)' },
399
+ },
400
+ output: {
401
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
402
+ render: (_a, a) => [{ type: 'text', text: a.text }],
403
+ },
404
+ async execute(args) {
405
+ const p = normalizeRemotePath(String(args.path || ''))
406
+ if (!p) throw new Error('rw_read_file: path is required')
407
+ const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
408
+ let from = Math.max(Number(args.startLine) || 1, 1)
409
+ let to = Number(args.endLine) || 0
410
+ if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
411
+ const raw = await run(`sed -n '${from},${to}p' -- ${shq(p)}`, { timeoutMs: config.commandTimeoutMs })
412
+ const numbered = raw.split('\n').map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
413
+ let text = numbered === '' ? '(empty or out of range)' : numbered
414
+ if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
415
+ return { text }
416
+ },
417
+ }),
418
+
419
+ defineTool({
420
+ name: 'rw_exec',
421
+ description:
422
+ 'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped.',
423
+ parameters: {
424
+ command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
425
+ },
426
+ output: {
427
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
428
+ render: (_a, a) => [{ type: 'text', text: a.text }],
429
+ },
430
+ async execute(args) {
431
+ const cmd = String(args.command || '')
432
+ if (!cmd) throw new Error('rw_exec: command is required')
433
+ return { text: await run(cmd, { timeoutMs: config.commandTimeoutMs }) }
434
+ },
435
+ }),
436
+ ]
437
+
438
+ for (const t of tools) {
439
+ ctx.tools.register(t)
440
+ }
441
+
442
+ // ── system-prompt injection: the current remote workspace ─────────────────
443
+ ctx.systemPrompt.section({
444
+ name: 'dsh-remote',
445
+ order: 88,
446
+ text: () => {
447
+ const w = wsPath()
448
+ if (!w || !config.host) return ''
449
+ return (
450
+ '## Remote workspace\n' +
451
+ `Current remote workspace: ${config.username}@${config.host}:${w}\n` +
452
+ '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.'
453
+ )
454
+ },
455
+ })
456
+
457
+ // ── slash command: /remote reports status + connection hints ──────────────
458
+ const commands = ctx.get('commands')
459
+ if (commands !== undefined) {
460
+ commands.register({
461
+ name: 'remote',
462
+ description: 'Show the current remote workspace / connection status and how to use remote tools.',
463
+ handler: (invocation) => {
464
+ const s = status()
465
+ return {
466
+ kind: 'success',
467
+ text:
468
+ `Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected})\n` +
469
+ `Remote workspace: ${s.workspace || '(none)'}\n` +
470
+ `\nUse tools: rw_list_dir / rw_read_file / rw_exec.` +
471
+ (s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
472
+ }
473
+ },
474
+ })
475
+ }
476
+
477
+ // ── JSON endpoints for settings UI ─────────────────────────────────────────
478
+ const webServer = ctx.get('webServer')
479
+ if (webServer === undefined) return
480
+
481
+ const sendJson = (res, status, body) => {
482
+ res.statusCode = status
483
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
484
+ res.end(JSON.stringify(body))
485
+ }
486
+ const readBody = (req) =>
487
+ new Promise((resolve) => {
488
+ const chunks = []
489
+ req.on('data', (c) => chunks.push(c))
490
+ req.on('end', () => resolve(chunks.join('')))
491
+ })
492
+
493
+ const routes = [
494
+ {
495
+ kind: 'exact',
496
+ path: '/dsh-remote/status',
497
+ handler: async (req, res) => {
498
+ if (req.method === 'GET') return sendJson(res, 200, status())
499
+ sendJson(res, 405, { error: 'method not allowed' })
500
+ },
501
+ },
502
+ {
503
+ kind: 'exact',
504
+ path: '/dsh-remote/connect',
505
+ handler: async (req, res) => {
506
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
507
+ try {
508
+ const payload = JSON.parse((await readBody(req)) || '{}')
509
+ pool.setTarget({
510
+ host: payload.host,
511
+ port: payload.port,
512
+ username: payload.username,
513
+ password: payload.password !== undefined && payload.password !== '' ? payload.password : undefined,
514
+ privateKeyPath: payload.privateKeyPath,
515
+ workspace: payload.workspace,
516
+ })
517
+ await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
518
+ return sendJson(res, 200, { ok: true, ...status() })
519
+ } catch (err) {
520
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
521
+ }
522
+ },
523
+ },
524
+ {
525
+ kind: 'exact',
526
+ path: '/dsh-remote/ls',
527
+ handler: async (req, res) => {
528
+ try {
529
+ const m = (req.url || '').match(/path=([^&]*)/)
530
+ const p = m ? decodeURIComponent(m[1]) : wsPath()
531
+ const out = await run(`ls -la --color=never ${shq(p || '/')}`)
532
+ return sendJson(res, 200, { path: p, text: out })
533
+ } catch (err) {
534
+ return sendJson(res, 500, { error: String((err && err.message) || err) })
535
+ }
536
+ },
537
+ },
538
+ {
539
+ kind: 'exact',
540
+ path: '/dsh-remote/workspace',
541
+ handler: async (req, res) => {
542
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
543
+ try {
544
+ const payload = JSON.parse((await readBody(req)) || '{}')
545
+ const p = normalizeRemotePath(String(payload.path || ''))
546
+ if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
547
+ const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
548
+ if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
549
+ config.workspace = p
550
+ return sendJson(res, 200, { ok: true, workspace: p, ...status() })
551
+ } catch (err) {
552
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
553
+ }
554
+ },
555
+ },
556
+ ]
557
+
558
+ const disposers = routes.map((r) => webServer.register(r))
559
+ ctx.effect(() => () => disposers.forEach((d) => d && d()), 'dsh-remote.routes')
558
560
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",