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 +50 -46
- package/README.zh.md +51 -49
- package/lib/client.js +105 -89
- package/lib/index.js +522 -88
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,81 +1,85 @@
|
|
|
1
1
|
# dsh-remote
|
|
2
2
|
|
|
3
|
-
Remote-
|
|
3
|
+
Remote-work assistant for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH).
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
20
|
+
dsh plugin --profile web add dsh-remote
|
|
15
21
|
```
|
|
16
22
|
|
|
17
|
-
|
|
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
|
-
###
|
|
27
|
+
### 1. Configure a default host (optional)
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
In `cordis.patch.yml`:
|
|
29
30
|
|
|
30
|
-
```
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
46
|
-
ssh -N -R 3080:127.0.0.1:3080 root@9.134.186.191
|
|
53
|
+
The agent uses:
|
|
47
54
|
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 →
|
|
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
|
-
|
|
3
|
+
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)的远程工作助手。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
连接一台 SSH 主机,选取一个**远程工作区**目录,让 Agent 在不离开 harness 的情况下直接对它操作——列目录、读文件、跑命令。
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
20
|
+
dsh plugin --profile web add dsh-remote
|
|
15
21
|
```
|
|
16
22
|
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
| `
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
75
|
+
## 浏览器页
|
|
74
76
|
|
|
75
|
-
|
|
77
|
+
设置 → 远程工作区:连接表单(host/port/user/密码|key)、目录浏览(`/dsh-remote/ls`)、「设为远程工作区」(`/dsh-remote/workspace`)。全部走 harness `webServer` 的同源 JSON 路由;SSH 池在 host 端,凭据只在本地回环上提交。
|
|
76
78
|
|
|
77
|
-
|
|
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 —
|
|
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
|
-
//
|
|
4
|
-
//
|
|
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
|
|
23
|
-
const
|
|
24
|
-
|
|
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
|
|
31
|
-
const [
|
|
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 [
|
|
34
|
-
const [copied, setCopied] = React.useState(false)
|
|
38
|
+
const [okMsg, setOkMsg] = React.useState('')
|
|
35
39
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
.then(
|
|
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
|
-
|
|
46
|
+
loadStatus()
|
|
44
47
|
}, [])
|
|
45
48
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
96
|
+
const row = (t, v, action, key) =>
|
|
59
97
|
React.createElement(
|
|
60
98
|
'div',
|
|
61
|
-
{ key
|
|
62
|
-
React.createElement('
|
|
63
|
-
|
|
64
|
-
|
|
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 } }, '
|
|
90
|
-
React.createElement(
|
|
91
|
-
'
|
|
92
|
-
|
|
93
|
-
React.createElement('
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
'
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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(
|
|
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-
|
|
1
|
+
// dsh-remote — remote-work assistant for DeepSeek Harness.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// •
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
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
|
-
//
|
|
16
|
-
|
|
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
|
-
/**
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
74
|
+
return '/' + parts.join('/')
|
|
33
75
|
}
|
|
34
76
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
// ──
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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.
|
|
4
|
-
"description": "Remote-
|
|
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
|
}
|