dsh-remote 0.1.4 → 0.2.0

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
@@ -1,81 +1,85 @@
1
1
  # dsh-remote
2
2
 
3
- Remote-access assistant for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH).
3
+ Remote-work assistant for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH).
4
4
 
5
- The harness Web GUI intentionally listens on `127.0.0.1` only — the CLI rejects `--host 0.0.0.0` for safety (the GUI has no auth and the agent executes code). That means remote access is a **tunneling workflow**, and this plugin makes it copy-paste easy:
5
+ Connect an SSH host, pick a **remote workspace** directory, and let the agent operate on it — list dirs, read files, run commands — without leaving the harness.
6
6
 
7
- - **`/remote` slash command** — prints the exact commands for this instance
8
- - **Settings → 远程访问 (Remote Access)** — live port, LAN IPs, trusted hosts and command blocks with copy buttons
9
- - Covers the three supported paths: SSH local forward (`ssh -L`), keepalive (`autossh`), reverse tunnel (`ssh -R`, NAT-friendly), and reverse-proxy access with `--trusted-host`
7
+ The harness Web UI intentionally binds `127.0.0.1` (the CLI rejects `--host 0.0.0.0` for safety). This plugin goes the other way: **you connect out** to a remote host over SSH and work in a remote directory.
8
+
9
+ ## What it gives you
10
+
11
+ - **Connect** a remote host with **password or SSH key** (SSH/SFTP via `ssh2`).
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`.
14
+ - **Settings → 远程工作区** — enter host/login, connect, browse the remote filesystem, set the workspace, all from the UI.
15
+ - The current remote workspace (`user@host:/path`) is injected into every system prompt so the agent knows the working root.
10
16
 
11
17
  ## Install
12
18
 
13
19
  ```bash
14
- npm install dsh-remote
20
+ dsh plugin --profile web add dsh-remote
15
21
  ```
16
22
 
17
- Add a row to your profile `cordis.yml` (or `cordis.patch.yml`):
18
-
19
- ```yaml
20
- - id: dsh-remote
21
- name: dsh-remote
22
- ```
23
+ (adds the bundle; the row is `id: dsh-remote`, `name: dsh-remote`).
23
24
 
24
25
  ## Usage
25
26
 
26
- ### From the chat
27
+ ### 1. Configure a default host (optional)
27
28
 
28
- Type `/remote` (optionally `/remote user@host`):
29
+ In `cordis.patch.yml`:
29
30
 
30
- ```
31
- /remote root@9.134.186.191
31
+ ```yaml
32
+ - id: dsh-remote
33
+ name: dsh-remote
34
+ config:
35
+ host: 10.0.0.8
36
+ port: 22
37
+ username: dev
38
+ privateKeyPath: C:/Users/you/.ssh/id_rsa
39
+ # OR use password login:
40
+ # password: '…'
41
+ workspace: /home/dev/project
32
42
  ```
33
43
 
34
- Output:
44
+ If `host` is empty the plugin starts disconnected and you connect at runtime.
35
45
 
36
- ```
37
- ## dsh-remote — remote access
46
+ ### 2. Connect + pick a workspace
38
47
 
39
- Web GUI: http://127.0.0.1:3080
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.
40
50
 
41
- ### Tunnel from your PC (recommended — harness stays loopback)
42
- ssh -N -L 18080:127.0.0.1:3080 root@9.134.186.191
43
- autossh -M 0 -N -L 18080:127.0.0.1:3080 root@9.134.186.191
51
+ ### 3. Work in the remote workspace
44
52
 
45
- ### Reverse tunnel (remote pushes out; NAT-friendly)
46
- ssh -N -R 3080:127.0.0.1:3080 root@9.134.186.191
53
+ The agent uses:
47
54
 
48
- ### Direct web access behind a reverse proxy (add auth yourself!)
49
- dsh --profile web --port 3080 --trusted-host <your-public-host>
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
50
59
  ```
51
60
 
52
- Run the `ssh -N -L ...` line on your PC, then open **http://127.0.0.1:18080** in the browser. Because the browser origin stays loopback, the harness `/api` trust fence accepts it with no extra flags — and the connection is SSH-encrypted and authenticated.
53
-
54
- ### From the settings page
55
-
56
- Settings → 远程访问 shows the same information, plus the machine's LAN IPs, with one-click copy per command. Entering `user@host` regenerates the commands with that target.
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.
57
62
 
58
63
  ## Configuration
59
64
 
60
65
  | Key | Type | Default | Meaning |
61
66
  | --- | --- | --- | --- |
62
- | `localPort` | int | 18080 | Local port used by the generated `ssh -L` / `autossh` commands (the PC side) |
63
-
64
- ```yaml
65
- - id: dsh-remote
66
- name: dsh-remote
67
- config:
68
- localPort: 20080
69
- ```
70
-
71
- ## Why not just bind 0.0.0.0?
72
-
73
- The harness deliberately refuses `--host 0.0.0.0` (`error: --host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network`). The GUI has no authentication, and the agent can execute code in the sandbox — binding every interface would hand remote code execution to anyone on the network. Tunnels (SSH or an authenticated reverse proxy) are the supported way, and that is what this plugin automates.
67
+ | `host` | string | `''` | Remote SSH host (empty = start disconnected) |
68
+ | `port` | int | `22` | Remote SSH port |
69
+ | `username` | string | `''` | SSH login user |
70
+ | `password` | string | `''` | SSH password (overrides the key when non-empty) |
71
+ | `privateKeyPath` | string | `''` | Absolute key path; empty → `~/.ssh/id_rsa` |
72
+ | `passphrase` | string | `''` | Key passphrase if encrypted |
73
+ | `workspace` | string | `''` | Initial remote workspace dir |
74
+ | `commandTimeoutMs` | int | 20000 | Per remote command timeout |
75
+ | `connectTimeoutMs` | int | 15000 | SSH connect timeout |
74
76
 
75
77
  ## Browser page
76
78
 
77
- Settings → 远程访问 (Remote Access). The page fetches `GET /dsh-remote/info` from the harness `webServer` service (same-origin JSON route).
79
+ Settings → 远程工作区: connect form (host/port/user/password|key), directory browse (`/dsh-remote/ls`), and "设为远程工作区" (`/dsh-remote/workspace`). All same-origin JSON routes on the harness `webServer`; the SSH pool lives in the host half, so credentials are only posted to loopback.
80
+
81
+ **Safety note**: giving the plugin remote credentials lets the agent run shell commands on that host **as your user**. Grant only on hosts you trust.
78
82
 
79
83
  ## License
80
84
 
81
- MIT
85
+ MIT
package/README.zh.md CHANGED
@@ -1,81 +1,83 @@
1
1
  # dsh-remote
2
2
 
3
- 为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)打造的**远程接入助手**插件。
3
+ [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)的远程工作助手。
4
4
 
5
- Harness 的 Web GUI **刻意只监听 `127.0.0.1`**——CLI 直接拒绝 `--host 0.0.0.0`(安全设计:GUI 无鉴权、Agent 能执行代码,绑全网等于把远程代码执行暴露给网络)。所以远程访问本质上是个**隧道工作流**,这个插件把它变成"复制粘贴":
5
+ 连接一台 SSH 主机,选取一个**远程工作区**目录,让 Agent 在不离开 harness 的情况下直接对它操作——列目录、读文件、跑命令。
6
6
 
7
- - **`/remote` 斜杠命令** —— 直接打印本实例的精确隧道命令
8
- - **设置 → 远程访问** —— 实时端口、局域网 IP、trusted hosts 与带一键复制的命令块
9
- - 覆盖三条官方支持的路径:SSH 本地转发(`ssh -L`)、保活(`autossh`)、反向隧道(`ssh -R`,NAT 友好)、反向代理直达(`--trusted-host`)
7
+ DSH Web 界面刻意只监听 `127.0.0.1`(`--host 0.0.0.0` 被安全地拒绝)。这个插件的思路相反:**由你主动向外连**到远程主机,在远程目录里工作。
8
+
9
+ ## 能力
10
+
11
+ - **连接远程**:支持**密码或 SSH key**(基于 `ssh2`)。
12
+ - **选取远程工作区**:把远程某个目录当作本会话的项目根。
13
+ - **模型工具**:`remote_info`、`remote_connect`、`remote_pick_workspace`、`remote_list_dir`、`remote_read_file`、`remote_exec`。
14
+ - **设置 → 远程工作区**:输入主机与登录方式→连接→浏览远程文件系统→设为工作区。
15
+ - 当前远程工作区(`user@host:/path`)会注入每次系统提示,让 Agent 明确知道工作根目录。
10
16
 
11
17
  ## 安装
12
18
 
13
19
  ```bash
14
- npm install dsh-remote
20
+ dsh plugin --profile web add dsh-remote
15
21
  ```
16
22
 
17
- 在你的 profile `cordis.yml`(或 `cordis.patch.yml`)里加一行:
23
+ (bundle 安装;行内容是 `id: dsh-remote`、`name: dsh-remote`。)
24
+
25
+ ## 使用
26
+
27
+ ### 1. 可选:在配置里设默认主机
28
+
29
+ `cordis.patch.yml`:
18
30
 
19
31
  ```yaml
20
32
  - id: dsh-remote
21
33
  name: dsh-remote
34
+ config:
35
+ host: 10.0.0.8
36
+ port: 22
37
+ username: dev
38
+ privateKeyPath: C:/Users/you/.ssh/id_rsa
39
+ # 或用密码登录:
40
+ # password: '…'
41
+ workspace: /home/dev/project
22
42
  ```
23
43
 
24
- ## 用法
44
+ 若 `host` 为空,插件启动时处于断开状态,可在运行时连接。
25
45
 
26
- ### 聊天里
46
+ ### 2. 连接并选工作区
27
47
 
28
- 输入 `/remote`(可选带 `/remote user@host`):
48
+ - **UI**:设置 → 远程工作区 → 填 host/port/user +(密码或 key 路径)→ **连接远程** → 输入远程路径并按 **设为远程工作区**(或用 **列目录** 浏览)。
49
+ - **让 Agent 做**:`remote_connect(host)` 之后 `remote_pick_workspace(path=/…/project)`;随时可用 `/remote` 看状态。
29
50
 
30
- ```
31
- /remote root@9.134.186.191
32
- ```
33
-
34
- 输出:
51
+ ### 3. 在远程工作区里工作
35
52
 
53
+ Agent 使用:
36
54
  ```
37
- ## dsh-remote — remote access
38
-
39
- Web GUI: http://127.0.0.1:3080
40
-
41
- ### Tunnel from your PC (recommended — harness stays loopback)
42
- ssh -N -L 18080:127.0.0.1:3080 root@9.134.186.191
43
- autossh -M 0 -N -L 18080:127.0.0.1:3080 root@9.134.186.191
44
-
45
- ### Reverse tunnel (remote pushes out; NAT-friendly)
46
- ssh -N -R 3080:127.0.0.1:3080 root@9.134.186.191
47
-
48
- ### Direct web access behind a reverse proxy (add auth yourself!)
49
- dsh --profile web --port 3080 --trusted-host <your-public-host>
55
+ remote_list_dir(path?)
56
+ remote_read_file(path=…)
57
+ remote_exec(command=…)
50
58
  ```
51
-
52
- 在你 PC 上跑 `ssh -N -L ...` 那行,然后浏览器打开 **http://127.0.0.1:18080**。因为浏览器源仍是 loopback,harness 的 `/api` 信任围栏直接放行、无需任何额外参数——且连接走 SSH 加密认证。
53
-
54
- ### 设置页
55
-
56
- 设置 → 远程访问:同样的信息 + 本机局域网 IP,每个命令一键复制;输入 `user@host` 可按目标重新生成命令。
59
+ 由于工作区路径在系统提示里,Agent 会把它当作工作根,组合这些工具对远程项目做查看/构建/测试。
57
60
 
58
61
  ## 配置
59
62
 
60
- | 键 | 类型 | 默认 | 含义 |
63
+ | 键 | 类型 | 默认 | 说明 |
61
64
  | --- | --- | --- | --- |
62
- | `localPort` | int | 18080 | 生成的 `ssh -L` / `autossh` 命令里的本机端口(PC 侧) |
63
-
64
- ```yaml
65
- - id: dsh-remote
66
- name: dsh-remote
67
- config:
68
- localPort: 20080
69
- ```
70
-
71
- ## 为什么不能直接绑 0.0.0.0?
65
+ | `host` | string | `''` | 远程 SSH 主机(空=断开) |
66
+ | `port` | int | `22` | 远程 SSH 端口 |
67
+ | `username` | string | `''` | 登录用户 |
68
+ | `password` | string | `''` | SSH 密码(非空则覆盖 key) |
69
+ | `privateKeyPath` | string | `''` | 私钥绝对路径;空=`~/.ssh/id_rsa` |
70
+ | `passphrase` | string | `''` | 私钥口令(若加密) |
71
+ | `workspace` | string | `''` | 初始远程工作区目录 |
72
+ | `commandTimeoutMs` | int | 20000 | 单条命令超时 |
73
+ | `connectTimeoutMs` | int | 15000 | SSH 连接超时 |
72
74
 
73
- Harness 刻意拒绝 `--host 0.0.0.0`(报错原文:`intentionally not supported yet for safety: it would expose remote code execution to the network`)。GUI 没有登录鉴权,而 Agent 能在沙箱里执行代码——绑全网 = 把远程代码执行交给网段里任何人。隧道(SSH 或带鉴权的反向代理)才是官方支持的方式,这正是本插件自动化的东西。
75
+ ## 浏览器页
74
76
 
75
- ## 浏览器页面
77
+ 设置 → 远程工作区:连接表单(host/port/user/密码|key)、目录浏览(`/dsh-remote/ls`)、「设为远程工作区」(`/dsh-remote/workspace`)。全部走 harness `webServer` 的同源 JSON 路由;SSH 池在 host 端,凭据只在本地回环上提交。
76
78
 
77
- 设置 → 远程访问。页面通过 harness 官方 `webServer` 服务拉取 `GET /dsh-remote/info`(同源 JSON 路由)。
79
+ **安全提醒**:把远程凭据交给插件,等于允许 Agent 以你的用户身份在该主机上执行 shell 命令。只对可信主机开放。
78
80
 
79
81
  ## License
80
82
 
81
- MIT
83
+ MIT
package/lib/client.js CHANGED
@@ -1,13 +1,12 @@
1
- // dsh-remote — remote-access assistant for DeepSeek Harness (Client half).
1
+ // dsh-remote — client half.
2
+ // Settings → 远程工作区: enter remote host/login (password or key path), connect,
3
+ // browse the remote filesystem, and pick a directory as the active remote workspace.
4
+ // All data flows through same-origin JSON routes on the harness `webServer`
5
+ // (/dsh-remote/status, /connect, /ls, /workspace). The host half owns the SSH pool,
6
+ // so no credentials live in the browser beyond what the form posts to loopback.
2
7
  //
3
- // Settings → 远程访问 (Remote Access): shows the live port, LAN IPs, trusted
4
- // hosts and the exact SSH tunnel / reverse-tunnel / reverse-proxy commands,
5
- // with a copy button. Data comes from the host half's JSON endpoint
6
- // (GET /dsh-remote/info) served by the official `webServer` service.
7
- //
8
- // Client entries must be classic scripts that register via
9
- // window.__ModuleLoader__.load({ id, factory }); the factory receives a
10
- // synchronous `require` and returns the module exports.
8
+ // Client entries must be classic scripts registered via window.__ModuleLoader__.load
9
+ // ({ id, factory }); the factory receives a synchronous `require`.
11
10
  window.__ModuleLoader__.load({
12
11
  id: 'dsh-remote',
13
12
  factory: (require) => {
@@ -16,117 +15,134 @@ window.__ModuleLoader__.load({
16
15
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
17
16
 
18
17
  const React = require('react')
19
-
20
18
  const name = 'dsh-remote'
21
19
 
22
- async function fetchInfo(target) {
23
- const q = target ? '?target=' + encodeURIComponent(target) : ''
24
- const res = await fetch('/dsh-remote/info' + q)
20
+ async function api(method, path, body) {
21
+ const opts = { method, headers: {} }
22
+ if (body) {
23
+ opts.headers['Content-Type'] = 'application/json'
24
+ opts.body = JSON.stringify(body)
25
+ }
26
+ const res = await fetch(path, opts)
25
27
  const data = await res.json().catch(() => ({}))
26
- if (!res.ok) throw new Error((data && data.error) || 'HTTP ' + res.status)
28
+ if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
27
29
  return data
28
30
  }
29
31
 
30
- function RemotePage() {
31
- const [info, setInfo] = React.useState(null)
32
+ function RemoteWorkspacePage() {
33
+ const [status, setStatus] = React.useState(null)
34
+ const [form, setForm] = React.useState({ host: '', port: '22', username: '', password: '', privateKeyPath: '' })
35
+ const [busy, setBusy] = React.useState(false)
36
+ const [browse, setBrowse] = React.useState({ path: '', out: '' })
32
37
  const [error, setError] = React.useState('')
33
- const [target, setTarget] = React.useState('')
34
- const [copied, setCopied] = React.useState(false)
38
+ const [okMsg, setOkMsg] = React.useState('')
35
39
 
36
- const refresh = (t) => {
37
- fetchInfo(t || '')
38
- .then(setInfo)
40
+ const loadStatus = () => {
41
+ api('GET', '/dsh-remote/status')
42
+ .then(setStatus)
39
43
  .catch((err) => setError(String((err && err.message) || err)))
40
44
  }
41
-
42
45
  React.useEffect(() => {
43
- refresh('')
46
+ loadStatus()
44
47
  }, [])
45
48
 
46
- const copy = (text) => {
47
- if (navigator && navigator.clipboard) {
48
- navigator.clipboard
49
- .writeText(text)
50
- .then(() => {
51
- setCopied(true)
52
- setTimeout(() => setCopied(false), 1500)
53
- })
54
- .catch(() => {})
55
- }
49
+ const setField = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
50
+
51
+ const connect = () => {
52
+ setBusy(true)
53
+ setError('')
54
+ setOkMsg('')
55
+ api('POST', '/dsh-remote/connect', {
56
+ host: form.host.trim(),
57
+ port: Number(form.port) || 22,
58
+ username: form.username.trim() || 'root',
59
+ password: form.password || undefined,
60
+ privateKeyPath: form.privateKeyPath.trim() || undefined,
61
+ })
62
+ .then((res) => {
63
+ setStatus(res)
64
+ setOkMsg(res.ok ? '已连接 ✓ 可浏览远程目录。' : '')
65
+ if (res && res.workspace) setBrowse({ path: res.workspace, out: '' })
66
+ })
67
+ .catch((err) => setError(String((err && err.message) || err)))
68
+ .finally(() => setBusy(false))
69
+ }
70
+
71
+ const doBrowse = (p) => {
72
+ const target = p !== undefined ? p : browse.path.trim()
73
+ setBusy(true)
74
+ setError('')
75
+ api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(target || '/'))
76
+ .then((res) => setBrowse({ path: target, out: res.text }))
77
+ .catch((err) => setError(String((err && err.message) || err)))
78
+ .finally(() => setBusy(false))
79
+ }
80
+
81
+ const setWorkspace = () => {
82
+ const p = browse.path.trim()
83
+ if (!p) return
84
+ setBusy(true)
85
+ setError('')
86
+ setOkMsg('')
87
+ api('POST', '/dsh-remote/workspace', { path: p })
88
+ .then((res) => {
89
+ setStatus(res)
90
+ setOkMsg('远程工作区已设为 ' + p)
91
+ })
92
+ .catch((err) => setError(String((err && err.message) || err)))
93
+ .finally(() => setBusy(false))
56
94
  }
57
95
 
58
- const block = (title, code) =>
96
+ const row = (t, v, action, key) =>
59
97
  React.createElement(
60
98
  'div',
61
- { key: title, style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
62
- React.createElement('div', { style: { fontSize: '13px', fontWeight: 600 } }, title),
63
- React.createElement('code', {
64
- style: {
65
- display: 'block',
66
- padding: '8px 10px',
67
- borderRadius: '6px',
68
- border: '1px solid rgba(128,128,128,0.35)',
69
- fontSize: '12px',
70
- whiteSpace: 'pre-wrap',
71
- wordBreak: 'break-all',
72
- },
73
- }, code),
74
- React.createElement('button', { onClick: () => copy(code), style: { alignSelf: 'flex-start' } }, '复制'),
99
+ { key },
100
+ React.createElement('label', { style: { fontSize: '12px', opacity: 0.75 } }, t + ': '),
101
+ v,
102
+ action || null,
75
103
  )
76
104
 
77
- if (error)
78
- return React.createElement('div', { style: { color: '#e06c75', padding: 16 } }, error)
79
- if (!info)
80
- return React.createElement('div', { style: { padding: 16, opacity: 0.6 } }, '加载中…')
81
-
82
- const c = info.commands || {}
83
- const lan = Array.isArray(info.lanAddresses) ? info.lanAddresses : []
84
- const trusted = Array.isArray(info.trustedHosts) ? info.trustedHosts : []
85
-
86
105
  return React.createElement(
87
106
  'div',
88
- { style: { padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px' } },
89
- React.createElement('div', { style: { fontWeight: 600 } }, '远程访问(dsh-remote)'),
90
- React.createElement(
91
- 'div',
92
- { style: { display: 'flex', gap: '16px', flexWrap: 'wrap', fontSize: '13px' } },
93
- React.createElement('span', null, 'Web GUI: ', React.createElement('code', null, info.url)),
94
- React.createElement('span', null, '端口: ', React.createElement('code', null, String(info.port))),
95
- lan.length
96
- ? React.createElement('span', null, '局域网: ', React.createElement('code', null, lan.join(', ')))
97
- : null,
98
- trusted.length
99
- ? React.createElement('span', null, 'trusted hosts: ', React.createElement('code', null, trusted.join(', ')))
100
- : null,
101
- ),
102
- React.createElement('div', { style: { fontSize: '12px', opacity: 0.75 } },
103
- '跨机器访问请用 SSH 隧道。harness 默认只监听 127.0.0.1,这是官方安全设计(GUI 无鉴权,--host 0.0.0.0 被刻意拒绝)。可选:输入 user@host 生成具体命令。',
107
+ { style: { padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px', maxWidth: 720 } },
108
+ React.createElement('div', { style: { fontWeight: 600, fontSize: 15 } }, '远程工作区(dsh-remote)'),
109
+ React.createElement('div', { style: { fontSize: '12px', opacity: 0.8 } },
110
+ '输入远程主机与登录方式,连接后在远程文件系统上浏览并选择工作区目录;agent 会用 remote_list_dir / remote_read_file / remote_exec 在所选远程工作区内操作。'),
111
+ status
112
+ ? React.createElement('div', { style: { display: 'flex', gap: '14px', flexWrap: 'wrap', fontSize: '13px' } },
113
+ React.createElement('span', null, '远程: ', React.createElement('code', null, (status.username || '?') + '@' + (status.host || '?'))),
114
+ React.createElement('span', null, '已连接: ', React.createElement('code', null, status.connected ? '是' : '否')),
115
+ React.createElement('span', null, '远程工作区: ', React.createElement('code', null, status.workspace || '(未设置)')),
116
+ )
117
+ : null,
118
+ React.createElement('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' } },
119
+ row('主机', React.createElement('input', { value: form.host, onChange: setField('host'), placeholder: 'IP 或 hostname', style: { flex: 1 } }), null, 'f1'),
120
+ row('端口', React.createElement('input', { value: form.port, onChange: setField('port'), placeholder: '22', style: { width: 60 } }), null, 'f2'),
121
+ row('用户', React.createElement('input', { value: form.username, onChange: setField('username'), placeholder: 'root', style: { flex: 1 } }), null, 'f3'),
122
+ row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setField('privateKeyPath'), placeholder: '如 C:/Users/me/.ssh/id_rsa(留空用默认 key)', style: { flex: 1 } }), null, 'f4'),
123
+ row('密码', React.createElement('input', { type: 'password', value: form.password, onChange: setField('password'), placeholder: '密码(SSH 无 key 时用;不进日志)', style: { flex: 1 } }), null, 'f5'),
104
124
  ),
125
+ React.createElement('button', { onClick: connect, disabled: busy }, busy ? '连接中…' : '连接远程'),
105
126
  React.createElement('div', { style: { display: 'flex', gap: '8px' } },
106
- React.createElement('input', {
107
- value: target,
108
- onChange: (ev) => setTarget(ev.target.value),
109
- placeholder: 'user@remote-host(可选)',
110
- style: { flex: 1 },
111
- }),
112
- React.createElement('button', { onClick: () => refresh(target) }, '生成'),
127
+ React.createElement('input', { value: browse.path, onChange: (ev) => setBrowse({ ...browse, path: ev.target.value }), placeholder: '浏览/作为工作区的路径,如 /home/dev/project', style: { flex: 1 } }),
128
+ React.createElement('button', { onClick: () => doBrowse(), disabled: busy }, '列目录'),
129
+ React.createElement('button', { onClick: setWorkspace, disabled: busy || !browse.path.trim() }, '设为远程工作区'),
113
130
  ),
114
- c.localForward ? block('本机 → 远程(ssh 本地转发,推荐)', c.localForward) : null,
115
- c.localForwardAuto ? block('autossh 保活版本', c.localForwardAuto) : null,
116
- c.reverseForward ? block('远程 → 本机(反向隧道,NAT 友好)', c.reverseForward) : null,
117
- c.trustedFlag ? block('反向代理直达(需自行加鉴权)', c.trustedFlag) : null,
118
- copied ? React.createElement('div', { style: { fontSize: '12px', opacity: 0.7 } }, '已复制 ✓') : null,
131
+ error ? React.createElement('div', { style: { color: '#e06c75', whiteSpace: 'pre-wrap', fontSize: 13 } }, error) : null,
132
+ okMsg ? React.createElement('div', { style: { color: '#4caf7d', fontSize: 13 } }, okMsg) : null,
133
+ browse.out
134
+ ? React.createElement('pre', { style: { fontSize: 12, background: 'rgba(128,128,128,0.08)', padding: 10, borderRadius: 6, maxHeight: 320, overflow: 'auto' } }, browse.out)
135
+ : null,
119
136
  )
120
137
  }
121
138
 
122
139
  function apply(ctx) {
123
140
  const slots = ctx.get('slots')
124
141
  if (slots === undefined) return
125
-
126
142
  slots.inject('settings.section', () =>
127
143
  slots.register(
128
- { name: 'settings.section', id: 'dsh-remote', order: 40, label: () => '远程访问' },
129
- () => React.createElement(RemotePage, null),
144
+ { name: 'settings.section', id: 'dsh-remote', order: 40, label: () => '远程工作区' },
145
+ () => React.createElement(RemoteWorkspacePage, null),
130
146
  ),
131
147
  )
132
148
  }
@@ -135,4 +151,4 @@ window.__ModuleLoader__.load({
135
151
  exports.apply = apply
136
152
  return module.exports
137
153
  },
138
- })
154
+ })
package/lib/index.js CHANGED
@@ -1,124 +1,558 @@
1
- // dsh-remote — remote-access assistant for DeepSeek Harness (Host half).
1
+ // dsh-remote — remote-work assistant for DeepSeek Harness.
2
2
  //
3
- // The harness Web server intentionally binds loopback only (the CLI rejects
4
- // --host 0.0.0.0 for safety: the GUI has no auth and the agent executes code).
5
- // This plugin does not fight that design — it makes the supported paths easy:
6
- // • a `/remote` slash command that prints the exact tunnel commands,
7
- // • a JSON endpoint (GET /dsh-remote/info) the settings page consumes.
8
- // Commands cover local-forward (ssh -L), keepalive (autossh), reverse tunnel
9
- // (ssh -R, NAT-friendly) and reverse-proxy (with --trusted-host) guidance.
10
- import { networkInterfaces } from 'node:os'
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).
11
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
12
26
 
13
27
  export const name = 'dsh-remote'
14
28
 
15
- // The web server is the hard dependency: its live port feeds every command.
16
- export const inject = ['webServer']
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']
17
32
 
18
- /** Optional deployment tuning. */
19
33
  export const Config = z.object({
20
- /** Local port used by the ssh -L / autossh commands generated for the PC side. */
21
- localPort: z.number().step(1).min(1).max(65535).default(18080),
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),
22
54
  })
23
55
 
24
- /** Non-loopback IPv4 addresses of this machine (empty when none are up). */
25
- function lanAddresses() {
26
- const out = []
27
- for (const addrs of Object.values(networkInterfaces())) {
28
- for (const addr of addrs ?? []) {
29
- if (addr.family === 'IPv4' && !addr.internal) out.push(addr.address)
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
30
71
  }
72
+ parts.push(seg)
31
73
  }
32
- return out
74
+ return '/' + parts.join('/')
33
75
  }
34
76
 
35
- export async function apply(ctx, config) {
36
- const port = ctx.webServer.port
37
- const url = `http://127.0.0.1:${port}`
38
- const lan = lanAddresses()
39
-
40
- const webStartup = ctx.get('webStartup')
41
- const trustedHosts =
42
- webStartup && Array.isArray(webStartup.trustedHosts) ? webStartup.trustedHosts : []
43
-
44
- const buildCommands = (target) => {
45
- const remote = target && target.trim() ? target.trim() : '<user@remote-host>'
46
- return {
47
- localForward: `ssh -N -L ${config.localPort}:127.0.0.1:${port} ${remote}`,
48
- localForwardAuto: `autossh -M 0 -N -L ${config.localPort}:127.0.0.1:${port} ${remote}`,
49
- reverseForward: `ssh -N -R ${port}:127.0.0.1:${port} ${remote}`,
50
- trustedFlag: `dsh --profile web --port ${port} --trusted-host <your-public-host>`,
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 {}
51
239
  }
52
240
  }
241
+ }
242
+
243
+ // ── helper: run + text ─────────────────────────────────────────────────────
53
244
 
54
- const info = (target) => ({
55
- port,
56
- url,
57
- lanAddresses: lan,
58
- trustedHosts,
59
- commands: buildCommands(target || ''),
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(),
60
270
  })
61
271
 
62
- // ── /remote slash command ────────────────────────────────────────────────
272
+ // ── tools ─────────────────────────────────────────────────────────────────
63
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 ──────────────
64
456
  const commands = ctx.get('commands')
65
457
  if (commands !== undefined) {
66
458
  commands.register({
67
459
  name: 'remote',
68
- description:
69
- 'Show remote-access tunnel commands for this harness instance. Optional argument: user@host for the ssh tunnel target.',
460
+ description: 'Show the current remote workspace / connection status and how to use remote tools.',
70
461
  handler: (invocation) => {
71
- const target = (invocation.rawInput || '').trim()
72
- const i = info(target)
73
- const remote = target || '<user@remote-host>'
74
- const lines = [
75
- '## dsh-remote — remote access',
76
- '',
77
- `Web GUI: ${i.url}`,
78
- ...(lan.length ? [`LAN IPs: ${lan.join(', ')}`] : []),
79
- ...(trustedHosts.length ? [`trusted hosts: ${trustedHosts.join(', ')}`] : []),
80
- '',
81
- '### Tunnel from your PC (recommended — harness stays loopback)',
82
- `ssh -N -L ${config.localPort}:127.0.0.1:${port} ${remote}`,
83
- `autossh -M 0 -N -L ${config.localPort}:127.0.0.1:${port} ${remote}`,
84
- '',
85
- '### Reverse tunnel (remote pushes out; NAT-friendly)',
86
- `ssh -N -R ${port}:127.0.0.1:${port} ${remote}`,
87
- '',
88
- '### Direct web access behind a reverse proxy (add auth yourself!)',
89
- `dsh --profile web --port ${port} --trusted-host <your-public-host>`,
90
- '',
91
- 'Note: --host 0.0.0.0 is intentionally rejected by the harness for safety.',
92
- ]
93
- return { kind: 'success', text: lines.join('\n') }
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
+ }
94
471
  },
95
472
  })
96
473
  }
97
474
 
98
- // ── JSON endpoint for the settings page ──────────────────────────────────
99
-
475
+ // ── JSON endpoints for settings UI ─────────────────────────────────────────
100
476
  const webServer = ctx.get('webServer')
101
477
  if (webServer === undefined) return
478
+
102
479
  const sendJson = (res, status, body) => {
103
480
  res.statusCode = status
104
481
  res.setHeader('Content-Type', 'application/json; charset=utf-8')
105
482
  res.end(JSON.stringify(body))
106
483
  }
107
- const disposeRoute = webServer.register({
108
- kind: 'exact',
109
- path: '/dsh-remote/info',
110
- handler: async (req, res) => {
111
- const query = (req.url || '').split('?')[1] || ''
112
- const match = query.match(/target=([^&]*)/)
113
- let target = ''
114
- try {
115
- target = match ? decodeURIComponent(match[1]) : ''
116
- } catch {
117
- target = ''
118
- }
119
- sendJson(res, 200, info(target))
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
+ },
120
499
  },
121
- })
122
- // Route is not fiber-bound: retain the disposer so stop/update never leaks.
123
- ctx.effect(() => disposeRoute, 'dsh-remote.routeDispose')
124
- }
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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.1.4",
4
- "description": "Remote-access assistant for DeepSeek Harness: /remote command and a settings page that print the exact SSH tunnel / reverse-tunnel / reverse-proxy commands for this instance (harness intentionally binds loopback only).",
3
+ "version": "0.2.0",
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",
7
7
  "dsh",
@@ -38,9 +38,12 @@
38
38
  "peerDependencies": {
39
39
  "@deepseek-ai/dsh-commands": "^0.1.0-rc.6",
40
40
  "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
41
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
42
+ "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
41
43
  "@deepseek-ai/cordis": "^4.0.1"
42
44
  },
43
45
  "dependencies": {
46
+ "ssh2": "^1.16.0",
44
47
  "@deepseek-ai/schemastery": "^3.18.1"
45
48
  }
46
49
  }