dsh-remote 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,85 +1,86 @@
1
- # dsh-remote
2
-
3
- Remote-work assistant for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH).
4
-
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
-
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** — `rw_info`, `rw_connect`, `rw_pick_workspace`, `rw_list_dir`, `rw_read_file`, `rw_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.
16
-
17
- ## Install
18
-
19
- ```bash
20
- dsh plugin --profile web add dsh-remote
21
- ```
22
-
23
- (adds the bundle; the row is `id: dsh-remote`, `name: dsh-remote`).
24
-
25
- ## Usage
26
-
27
- ### 1. Configure a default host (optional)
28
-
29
- In `cordis.patch.yml`:
30
-
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
42
- ```
43
-
44
- If `host` is empty the plugin starts disconnected and you connect at runtime.
45
-
46
- ### 2. Connect + pick a workspace
47
-
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 `rw_connect(host)`, then `rw_pick_workspace(path=/…/project)`. You can also use `/remote` to see the current status.
50
-
51
- ### 3. Work in the remote workspace
52
-
53
- The agent uses:
54
-
55
- ```
56
- rw_list_dir(path?) # list a remote dir (defaults to the workspace)
57
- rw_read_file(path=-…) # read a remote file (paged)
58
- rw_exec(command=…) # run any shell command on the remote
59
- ```
60
-
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.
62
-
63
- ## Configuration
64
-
65
- | Key | Type | Default | Meaning |
66
- | --- | --- | --- | --- |
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 |
76
-
77
- ## Browser page
78
-
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.
82
-
83
- ## License
84
-
1
+ # dsh-remote
2
+
3
+ Remote-work assistant for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH).
4
+
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
+
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** — `rw_info`, `rw_connect`, `rw_pick_workspace`, `rw_list_dir`, `rw_read_file`, `rw_exec`, `rw_sync`.
14
+ - **Local mirror** — picking a remote workspace also creates a **real local directory** (`~/.dsh/remote-workspaces/<host>/<base>-<hash>`) that mirrors it over SFTP. Because that path passes `fs.realpath`, the DSH **native workspace selector** can pick it and `createWorkspace({ path })` adopts it — so the browser/Session workspace sees the remote project as a normal local workspace, while dsh-remote keeps it in sync with the remote.
15
+ - **Settings → 远程工作区** — enter host/login, connect, browse the remote filesystem, set the workspace, all from the UI.
16
+ - The current remote workspace (`user@host:/path`) is injected into every system prompt so the agent knows the working root.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ dsh plugin --profile web add dsh-remote
22
+ ```
23
+
24
+ (adds the bundle; the row is `id: dsh-remote`, `name: dsh-remote`).
25
+
26
+ ## Usage
27
+
28
+ ### 1. Configure a default host (optional)
29
+
30
+ In `cordis.patch.yml`:
31
+
32
+ ```yaml
33
+ - id: dsh-remote
34
+ name: dsh-remote
35
+ config:
36
+ host: 10.0.0.8
37
+ port: 22
38
+ username: dev
39
+ privateKeyPath: C:/Users/you/.ssh/id_rsa
40
+ # OR use password login:
41
+ # password: '…'
42
+ workspace: /home/dev/project
43
+ ```
44
+
45
+ If `host` is empty the plugin starts disconnected and you connect at runtime.
46
+
47
+ ### 2. Connect + pick a workspace
48
+
49
+ - **From the UI**: Settings → 远程工作区 → enter host/port/user + (password or key path) → **连接远程** → type a remote path and **设为远程工作区** (or **列目录** to browse).
50
+ - **From the agent**: ask it to `rw_connect(host)`, then `rw_pick_workspace(path=/…/project)`. You can also use `/remote` to see the current status.
51
+
52
+ ### 3. Work in the remote workspace
53
+
54
+ The agent uses:
55
+
56
+ ```
57
+ rw_list_dir(path?) # list a remote dir (defaults to the workspace)
58
+ rw_read_file(path=-…) # read a remote file (paged)
59
+ rw_exec(command=…) # run any shell command on the remote
60
+ ```
61
+
62
+ 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.
63
+
64
+ ## Configuration
65
+
66
+ | Key | Type | Default | Meaning |
67
+ | --- | --- | --- | --- |
68
+ | `host` | string | `''` | Remote SSH host (empty = start disconnected) |
69
+ | `port` | int | `22` | Remote SSH port |
70
+ | `username` | string | `''` | SSH login user |
71
+ | `password` | string | `''` | SSH password (overrides the key when non-empty) |
72
+ | `privateKeyPath` | string | `''` | Absolute key path; empty → `~/.ssh/id_rsa` |
73
+ | `passphrase` | string | `''` | Key passphrase if encrypted |
74
+ | `workspace` | string | `''` | Initial remote workspace dir |
75
+ | `commandTimeoutMs` | int | 20000 | Per remote command timeout |
76
+ | `connectTimeoutMs` | int | 15000 | SSH connect timeout |
77
+
78
+ ## Browser page
79
+
80
+ 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.
81
+
82
+ **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.
83
+
84
+ ## License
85
+
85
86
  MIT
package/README.zh.md CHANGED
@@ -1,83 +1,84 @@
1
- # dsh-remote
2
-
3
- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)的远程工作助手。
4
-
5
- 连接一台 SSH 主机,选取一个**远程工作区**目录,让 Agent 在不离开 harness 的情况下直接对它操作——列目录、读文件、跑命令。
6
-
7
- DSH Web 界面刻意只监听 `127.0.0.1`(`--host 0.0.0.0` 被安全地拒绝)。这个插件的思路相反:**由你主动向外连**到远程主机,在远程目录里工作。
8
-
9
- ## 能力
10
-
11
- - **连接远程**:支持**密码或 SSH key**(基于 `ssh2`)。
12
- - **选取远程工作区**:把远程某个目录当作本会话的项目根。
13
- - **模型工具**:`rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_read_file`、`rw_exec`。
14
- - **设置 → 远程工作区**:输入主机与登录方式→连接→浏览远程文件系统→设为工作区。
15
- - 当前远程工作区(`user@host:/path`)会注入每次系统提示,让 Agent 明确知道工作根目录。
16
-
17
- ## 安装
18
-
19
- ```bash
20
- dsh plugin --profile web add dsh-remote
21
- ```
22
-
23
- (bundle 安装;行内容是 `id: dsh-remote`、`name: dsh-remote`。)
24
-
25
- ## 使用
26
-
27
- ### 1. 可选:在配置里设默认主机
28
-
29
- `cordis.patch.yml`:
30
-
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
- # 或用密码登录:
40
- # password: '…'
41
- workspace: /home/dev/project
42
- ```
43
-
44
- 若 `host` 为空,插件启动时处于断开状态,可在运行时连接。
45
-
46
- ### 2. 连接并选工作区
47
-
48
- - **UI**:设置 → 远程工作区 → 填 host/port/user +(密码或 key 路径)→ **连接远程** → 输入远程路径并按 **设为远程工作区**(或用 **列目录** 浏览)。
49
- - **让 Agent 做**:`rw_connect(host)` 之后 `rw_pick_workspace(path=/…/project)`;随时可用 `/remote` 看状态。
50
-
51
- ### 3. 在远程工作区里工作
52
-
53
- Agent 使用:
54
- ```
55
- rw_list_dir(path?)
56
- rw_read_file(path=…)
57
- rw_exec(command=…)
58
- ```
59
- 由于工作区路径在系统提示里,Agent 会把它当作工作根,组合这些工具对远程项目做查看/构建/测试。
60
-
61
- ## 配置
62
-
63
- | 键 | 类型 | 默认 | 说明 |
64
- | --- | --- | --- | --- |
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 连接超时 |
74
-
75
- ## 浏览器页
76
-
77
- 设置 → 远程工作区:连接表单(host/port/user/密码|key)、目录浏览(`/dsh-remote/ls`)、「设为远程工作区」(`/dsh-remote/workspace`)。全部走 harness `webServer` 的同源 JSON 路由;SSH 池在 host 端,凭据只在本地回环上提交。
78
-
79
- **安全提醒**:把远程凭据交给插件,等于允许 Agent 以你的用户身份在该主机上执行 shell 命令。只对可信主机开放。
80
-
81
- ## License
82
-
1
+ # dsh-remote
2
+
3
+ [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)的远程工作助手。
4
+
5
+ 连接一台 SSH 主机,选取一个**远程工作区**目录,让 Agent 在不离开 harness 的情况下直接对它操作——列目录、读文件、跑命令。
6
+
7
+ DSH Web 界面刻意只监听 `127.0.0.1`(`--host 0.0.0.0` 被安全地拒绝)。这个插件的思路相反:**由你主动向外连**到远程主机,在远程目录里工作。
8
+
9
+ ## 能力
10
+
11
+ - **连接远程**:支持**密码或 SSH key**(基于 `ssh2`)。
12
+ - **选取远程工作区**:把远程某个目录当作本会话的项目根。
13
+ - **模型工具**:`rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_read_file`、`rw_exec`、`rw_sync`。
14
+ - **本地镜像**:选远程工作区时会在本地 `~/.dsh/remote-workspaces/<host>/<base>-<hash>` 生成一个**真实本地目录**并经 SFTP 镜像。该路径通过 `fs.realpath`,因此 DSH 原生工作区选择器能选中它、`createWorkspace({path})` 能把它收养——浏览器/会话里的工作区把它当普通本地工作区,同时 dsh-remote 与远程保持同步。
15
+ - **设置 → 远程工作区**:输入主机与登录方式→连接→浏览远程文件系统→设为工作区。
16
+ - 当前远程工作区(`user@host:/path`)会注入每次系统提示,让 Agent 明确知道工作根目录。
17
+
18
+ ## 安装
19
+
20
+ ```bash
21
+ dsh plugin --profile web add dsh-remote
22
+ ```
23
+
24
+ (bundle 安装;行内容是 `id: dsh-remote`、`name: dsh-remote`。)
25
+
26
+ ## 使用
27
+
28
+ ### 1. 可选:在配置里设默认主机
29
+
30
+ `cordis.patch.yml`:
31
+
32
+ ```yaml
33
+ - id: dsh-remote
34
+ name: dsh-remote
35
+ config:
36
+ host: 10.0.0.8
37
+ port: 22
38
+ username: dev
39
+ privateKeyPath: C:/Users/you/.ssh/id_rsa
40
+ # 或用密码登录:
41
+ # password: '…'
42
+ workspace: /home/dev/project
43
+ ```
44
+
45
+ 若 `host` 为空,插件启动时处于断开状态,可在运行时连接。
46
+
47
+ ### 2. 连接并选工作区
48
+
49
+ - **UI**:设置 → 远程工作区 → 填 host/port/user +(密码或 key 路径)→ **连接远程** → 输入远程路径并按 **设为远程工作区**(或用 **列目录** 浏览)。
50
+ - **让 Agent 做**:`rw_connect(host)` 之后 `rw_pick_workspace(path=/…/project)`;随时可用 `/remote` 看状态。
51
+
52
+ ### 3. 在远程工作区里工作
53
+
54
+ Agent 使用:
55
+ ```
56
+ rw_list_dir(path?)
57
+ rw_read_file(path=…)
58
+ rw_exec(command=…)
59
+ ```
60
+ 由于工作区路径在系统提示里,Agent 会把它当作工作根,组合这些工具对远程项目做查看/构建/测试。
61
+
62
+ ## 配置
63
+
64
+ | 键 | 类型 | 默认 | 说明 |
65
+ | --- | --- | --- | --- |
66
+ | `host` | string | `''` | 远程 SSH 主机(空=断开) |
67
+ | `port` | int | `22` | 远程 SSH 端口 |
68
+ | `username` | string | `''` | 登录用户 |
69
+ | `password` | string | `''` | SSH 密码(非空则覆盖 key) |
70
+ | `privateKeyPath` | string | `''` | 私钥绝对路径;空=`~/.ssh/id_rsa` |
71
+ | `passphrase` | string | `''` | 私钥口令(若加密) |
72
+ | `workspace` | string | `''` | 初始远程工作区目录 |
73
+ | `commandTimeoutMs` | int | 20000 | 单条命令超时 |
74
+ | `connectTimeoutMs` | int | 15000 | SSH 连接超时 |
75
+
76
+ ## 浏览器页
77
+
78
+ 设置 → 远程工作区:连接表单(host/port/user/密码|key)、目录浏览(`/dsh-remote/ls`)、「设为远程工作区」(`/dsh-remote/workspace`)。全部走 harness `webServer` 的同源 JSON 路由;SSH 池在 host 端,凭据只在本地回环上提交。
79
+
80
+ **安全提醒**:把远程凭据交给插件,等于允许 Agent 以你的用户身份在该主机上执行 shell 命令。只对可信主机开放。
81
+
82
+ ## License
83
+
83
84
  MIT
package/lib/client.js CHANGED
@@ -1,154 +1,249 @@
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.
7
- //
8
- // Client entries must be classic scripts registered via window.__ModuleLoader__.load
9
- // ({ id, factory }); the factory receives a synchronous `require`.
10
- window.__ModuleLoader__.load({
11
- id: 'dsh-remote',
12
- factory: (require) => {
13
- var module = { exports: {} }
14
- var exports = module.exports
15
- Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
16
-
17
- const React = require('react')
18
- const name = 'dsh-remote'
19
-
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)
27
- const data = await res.json().catch(() => ({}))
28
- if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
29
- return data
30
- }
31
-
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: '' })
37
- const [error, setError] = React.useState('')
38
- const [okMsg, setOkMsg] = React.useState('')
39
-
40
- const loadStatus = () => {
41
- api('GET', '/dsh-remote/status')
42
- .then(setStatus)
43
- .catch((err) => setError(String((err && err.message) || err)))
44
- }
45
- React.useEffect(() => {
46
- loadStatus()
47
- }, [])
48
-
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))
94
- }
95
-
96
- const row = (t, v, action, key) =>
97
- React.createElement(
98
- 'div',
99
- { key },
100
- React.createElement('label', { style: { fontSize: '12px', opacity: 0.75 } }, t + ': '),
101
- v,
102
- action || null,
103
- )
104
-
105
- return React.createElement(
106
- 'div',
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'),
124
- ),
125
- React.createElement('button', { onClick: connect, disabled: busy }, busy ? '连接中…' : '连接远程'),
126
- React.createElement('div', { style: { display: 'flex', gap: '8px' } },
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() }, '设为远程工作区'),
130
- ),
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,
136
- )
137
- }
138
-
139
- function apply(ctx) {
140
- const slots = ctx.get('slots')
141
- if (slots === undefined) return
142
- slots.inject('settings.section', () =>
143
- slots.register(
144
- { name: 'settings.section', id: 'dsh-remote', order: 40, label: () => '远程工作区' },
145
- () => React.createElement(RemoteWorkspacePage, null),
146
- ),
147
- )
148
- }
149
-
150
- exports.name = name
151
- exports.apply = apply
152
- return module.exports
153
- },
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.
7
+ //
8
+ // Client entries must be classic scripts registered via window.__ModuleLoader__.load
9
+ // ({ id, factory }); the factory receives a synchronous `require`.
10
+ window.__ModuleLoader__.load({
11
+ id: 'dsh-remote',
12
+ factory: (require) => {
13
+ var module = { exports: {} }
14
+ var exports = module.exports
15
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
16
+
17
+ const React = require('react')
18
+ const name = 'dsh-remote'
19
+
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)
27
+ const data = await res.json().catch(() => ({}))
28
+ if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
29
+ return data
30
+ }
31
+
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: '' })
37
+ const [error, setError] = React.useState('')
38
+ const [okMsg, setOkMsg] = React.useState('')
39
+
40
+ const loadStatus = () => {
41
+ api('GET', '/dsh-remote/status')
42
+ .then(setStatus)
43
+ .catch((err) => setError(String((err && err.message) || err)))
44
+ }
45
+ React.useEffect(() => {
46
+ loadStatus()
47
+ }, [])
48
+
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))
94
+ }
95
+
96
+ const row = (t, v, action, key) =>
97
+ React.createElement(
98
+ 'div',
99
+ { key },
100
+ React.createElement('label', { style: { fontSize: '12px', opacity: 0.75 } }, t + ': '),
101
+ v,
102
+ action || null,
103
+ )
104
+
105
+ return React.createElement(
106
+ 'div',
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'),
124
+ ),
125
+ React.createElement('button', { onClick: connect, disabled: busy }, busy ? '连接中…' : '连接远程'),
126
+ React.createElement('div', { style: { display: 'flex', gap: '8px' } },
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() }, '设为远程工作区'),
130
+ ),
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,
136
+ )
137
+ }
138
+
139
+ // ── Remote directory picker: fills ui-workspace's directory-flow holes ──
140
+ // Adapter that lets the native "Add workspace" flow pick a REMOTE directory.
141
+ // It browses via /dsh-remote/ls (host SFTP) and, on confirm, asks the host to
142
+ // create a real LOCAL mirror (/dsh-remote/mirror) so the DSH workspaceRegistry
143
+ // (which requires fs.realpath on a local dir) can adopt it with onPicked(mirror).
144
+ function RemoteDirectoryFlow(props) {
145
+ const { open, busy, onPicked, onCancel } = props
146
+ const [path, setPath] = React.useState('')
147
+ const [entries, setEntries] = React.useState(null)
148
+ const [err, setErr] = React.useState('')
149
+ const [loading, setLoading] = React.useState(false)
150
+ React.useEffect(() => {
151
+ if (!open) return
152
+ setEntries(null)
153
+ setErr('')
154
+ setLoading(true)
155
+ api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(''))
156
+ .then((res) => {
157
+ setPath(res && res.path ? res.path : '')
158
+ setEntries(res && res.text ? res.text : '(empty)')
159
+ })
160
+ .catch((e) => setErr(String((e && e.message) || e)))
161
+ .finally(() => setLoading(false))
162
+ }, [open])
163
+
164
+ const browse = (p) => {
165
+ const target = p !== undefined ? p : path
166
+ setLoading(true)
167
+ setErr('')
168
+ api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(target || ''))
169
+ .then((res) => {
170
+ setPath(target)
171
+ setEntries(res.text)
172
+ })
173
+ .catch((e) => setErr(String((e && e.message) || e)))
174
+ .finally(() => setLoading(false))
175
+ }
176
+
177
+ const goUp = () => {
178
+ const idx = String(path).replace(/\/+$/, '').lastIndexOf('/')
179
+ browse(idx <= 0 ? '/' : String(path).slice(0, idx))
180
+ }
181
+
182
+ const pick = () => {
183
+ if (busy) return
184
+ setErr('')
185
+ api('POST', '/dsh-remote/mirror', { path })
186
+ .then((res) => {
187
+ if (res && res.localMirror) onPicked(res.localMirror)
188
+ else if (res && res.error) setErr(res.error)
189
+ })
190
+ .catch((e) => setErr(String((e && e.message) || e)))
191
+ }
192
+
193
+ if (!open) return null
194
+ return React.createElement(
195
+ 'div',
196
+ { style: { padding: 16, minWidth: 420, maxWidth: 640 } },
197
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: 8 } }, '远程工作区目录'),
198
+ React.createElement(
199
+ 'div',
200
+ { style: { display: 'flex', gap: 6, marginBottom: 8 } },
201
+ React.createElement('input', {
202
+ value: path,
203
+ onChange: (e) => setPath(e.target.value),
204
+ placeholder: '远程路径,如 /home/dev/project',
205
+ style: { flex: 1 },
206
+ }),
207
+ React.createElement('button', { onClick: () => browse(), disabled: loading }, '列目录'),
208
+ React.createElement('button', { onClick: goUp, disabled: loading }, '上一级'),
209
+ ),
210
+ err ? React.createElement('div', { style: { color: '#e06c75', marginBottom: 6, fontSize: 12 } }, err) : null,
211
+ React.createElement(
212
+ 'pre',
213
+ { style: { fontSize: 12, background: 'rgba(128,128,128,0.08)', padding: 8, borderRadius: 6, maxHeight: 260, overflow: 'auto', whiteSpace: 'pre-wrap' } },
214
+ loading ? '加载中…' : entries || '(未连接远程,先连接或用 rw_connect)',
215
+ ),
216
+ React.createElement(
217
+ 'div',
218
+ { style: { display: 'flex', gap: 8, marginTop: 8, justifyContent: 'flex-end' } },
219
+ React.createElement('button', { onClick: onCancel, style: { background: 'transparent' } }, '取消'),
220
+ React.createElement('button', { onClick: pick, disabled: busy || !path.trim() }, busy ? '本地镜像中…' : '设为远程工作区'),
221
+ ),
222
+ )
223
+ }
224
+
225
+ function apply(ctx) {
226
+ const slots = ctx.get('slots')
227
+ if (slots === undefined) return
228
+ slots.inject('settings.section', () =>
229
+ slots.register(
230
+ { name: 'settings.section', id: 'dsh-remote', order: 40, label: () => '远程工作区' },
231
+ () => React.createElement(RemoteWorkspacePage, null),
232
+ ),
233
+ )
234
+ // fill ui-workspace's two directory-flow holes with the remote picker
235
+ slots.inject(
236
+ 'conversation.hero.workspace.directoryFlow',
237
+ () =>
238
+ slots.inject('sidebar.workspaces.directoryFlow', function* () {
239
+ yield slots.register({ name: 'conversation.hero.workspace.directoryFlow', id: 'dsh-remote', priority: -100 }, RemoteDirectoryFlow)
240
+ yield slots.register({ name: 'sidebar.workspaces.directoryFlow', id: 'dsh-remote', priority: -100 }, RemoteDirectoryFlow)
241
+ }),
242
+ )
243
+ }
244
+
245
+ exports.name = name
246
+ exports.apply = apply
247
+ return module.exports
248
+ },
154
249
  })
package/lib/index.js CHANGED
@@ -18,8 +18,9 @@
18
18
  import z from '@deepseek-ai/schemastery'
19
19
  import { defineTool } from '@deepseek-ai/dsh-tools'
20
20
  import ssh2 from 'ssh2'
21
- import { readFileSync } from 'node:fs'
21
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'
22
22
  import { homedir } from 'node:os'
23
+ import { createHash } from 'node:crypto'
23
24
  import path from 'node:path'
24
25
 
25
26
  const { Client } = ssh2
@@ -89,6 +90,109 @@ function truncate(s, max) {
89
90
  return s.slice(0, max) + `\n…[truncated: ${s.length - max} more chars]`
90
91
  }
91
92
 
93
+ // ── local mirror of an OS remote workspace ────────────────────────────────
94
+ // Makes a chosen remote workspace a REAL local directory (fs.realpath passes),
95
+ // so the DSH host `workspaceRegistry.create(dir)` accepts it as a native
96
+ // workspace. dsh-remote syncs that local mirror <-> the remote over SFTP.
97
+
98
+ const remotePathBase = (p) => {
99
+ const norm = normalizeRemotePath(p).replace(/\/+$/, '')
100
+ const base = norm.split('/').pop()
101
+ return base || 'workspace'
102
+ }
103
+
104
+ /** Stable local root for one remote host's mirrors */
105
+ function mirrorRootFor(host, user, port) {
106
+ const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
107
+ return path.join(homedir(), '.dsh', 'remote-workspaces', tag)
108
+ }
109
+
110
+ /** Local mirror dir for a specific remote path (idempotent → returns same dir). */
111
+ function mirrorDirFor(remotePath, host, user, port) {
112
+ const base = remotePathBase(remotePath)
113
+ const h = createHash('sha1').update(`${host}:${port}:${remotePath}`).digest('hex').slice(0, 8)
114
+ return path.join(mirrorRootFor(host, user, port), `${base}-${h}`)
115
+ }
116
+
117
+ /** Create the local mirror dir + a meta file describing its remote origin. */
118
+ function ensureMirror(remotePath, host, user, port) {
119
+ const dir = mirrorDirFor(remotePath, host, user, port)
120
+ mkdirSync(dir, { recursive: true })
121
+ writeFileSync(
122
+ path.join(dir, '.dsh-remote-meta.json'),
123
+ JSON.stringify({ host, port, username: user, remotePath, createdAt: new Date().toISOString() }, null, 2),
124
+ )
125
+ return dir
126
+ }
127
+
128
+ /** Recursively sync remote → local mirror. Bounded by depth/files. */
129
+ async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles) {
130
+ const entries = await sftp.readdir(remoteDir).then(
131
+ (list) => list,
132
+ () => [],
133
+ )
134
+ let files = 0
135
+ const touched = []
136
+ for (const e of entries) {
137
+ const name = String(e.filename)
138
+ if (name === '.' || name === '..') continue
139
+ const rp = remoteDir === '/' ? '/' + name : remoteDir + '/' + name
140
+ const lp = path.join(localDir, name)
141
+ const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
142
+ if (isDir) {
143
+ if (maxDepth <= 0) continue
144
+ mkdirSync(lp, { recursive: true })
145
+ const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles)
146
+ files += sub.files
147
+ touched.push(...sub.touched)
148
+ continue
149
+ }
150
+ if (files >= maxFiles) break
151
+ try {
152
+ const buf = await sftp.readFile(rp)
153
+ writeFileSync(lp, buf)
154
+ touched.push(rp)
155
+ files++
156
+ } catch {
157
+ /* skip unreadable */
158
+ }
159
+ }
160
+ return { files, touched }
161
+ }
162
+
163
+ /** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files. */
164
+ async function pushTree(sftp, localDir, remoteDir, remoteBaseDir, maxFiles) {
165
+ const entries = readdirSync(localDir, { withFileTypes: true }).filter((e) => e.name !== '.dsh-remote-meta.json')
166
+ let files = 0
167
+ let dirsCreated = 0
168
+ const pushed = []
169
+ for (const e of entries) {
170
+ if (e.isDirectory()) {
171
+ const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
172
+ try {
173
+ await sftp.mkdir(rp)
174
+ dirsCreated++
175
+ } catch { /* already exists */ }
176
+ const sub = await pushTree(sftp, path.join(localDir, e.name), rp, remoteBaseDir, maxFiles)
177
+ files += sub.files
178
+ pushed.push(...sub.pushed)
179
+ continue
180
+ }
181
+ if (files >= maxFiles) break
182
+ const lp = path.join(localDir, e.name)
183
+ const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
184
+ try {
185
+ const buf = readFileSync(lp)
186
+ await sftp.writeFile(rp, buf)
187
+ pushed.push(rp)
188
+ files++
189
+ } catch {
190
+ /* skip unreadable / unwritable */
191
+ }
192
+ }
193
+ return { files, pushed }
194
+ }
195
+
92
196
  // ── SSH pool (key OR password) ──────────────────────────────────────────────
93
197
 
94
198
  class SshPool {
@@ -231,6 +335,25 @@ class SshPool {
231
335
  )
232
336
  }
233
337
 
338
+ /** Resolve a promisified SFTP client for binary file transfers (sync). */
339
+ sftp() {
340
+ return this.connect().then(
341
+ (client) =>
342
+ new Promise((resolve, reject) => {
343
+ client.sftp((err, sftp) => {
344
+ if (err) return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
345
+ resolve({
346
+ readdir: (dir) => new Promise((r2, j2) => sftp.readdir(dir, (e, list) => (e ? j2(e) : r2(list)))),
347
+ stat: (p) => new Promise((r2, j2) => sftp.stat(p, (e, st) => (e ? j2(e) : r2(st)))),
348
+ mkdir: (dir) => new Promise((r2, j2) => sftp.mkdir(dir, (e) => (e ? j2(e) : r2()) )),
349
+ readFile: (p) => new Promise((r2, j2) => sftp.readFile(p, (e, buf) => (e ? j2(e) : r2(buf)))),
350
+ writeFile: (p, data) => new Promise((r2, j2) => sftp.writeFile(p, data, (e) => (e ? j2(e) : r2()))),
351
+ })
352
+ })
353
+ }),
354
+ )
355
+ }
356
+
234
357
  close() {
235
358
  const client = this.client
236
359
  this.client = null
@@ -269,6 +392,7 @@ export async function apply(ctx, config) {
269
392
  username: config.username,
270
393
  connected: !!pool.client,
271
394
  workspace: wsPath(),
395
+ localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
272
396
  })
273
397
 
274
398
  // ── tools ─────────────────────────────────────────────────────────────────
@@ -365,7 +489,68 @@ export async function apply(ctx, config) {
365
489
  const ok = res.stdout.trim() === 'DIR'
366
490
  if (!ok) return { text: `not a directory (or missing) on ${p}` }
367
491
  config.workspace = p
368
- return { text: `Remote workspace set to ${p} on ${config.username}@${config.host}.` }
492
+ const local = ensureMirror(p, config.host, config.username, config.port)
493
+ return {
494
+ text: `Remote workspace set to ${p} on ${config.username}@${config.host}.\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
495
+ }
496
+ },
497
+ }),
498
+
499
+ defineTool({
500
+ name: 'rw_sync',
501
+ description:
502
+ 'Download the current remote workspace into its local mirror directory over SFTP (bounded). Makes the remote files visible/editable locally so the DSH native workspace / fs tools can operate on them.',
503
+ parameters: {
504
+ depth: { type: 'integer', description: 'Max directory depth to mirror (default 5)' },
505
+ maxFiles: { type: 'integer', description: 'Max files to download (default 500)' },
506
+ },
507
+ output: {
508
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
509
+ render: (_a, a) => [{ type: 'text', text: a.text }],
510
+ },
511
+ async execute(args) {
512
+ const p = wsPath()
513
+ if (!p) throw new Error('rw_sync: no remote workspace set — call rw_pick_workspace first')
514
+ const local = mirrorDirFor(p, config.host, config.username, config.port)
515
+ mkdirSync(local, { recursive: true })
516
+ const depth = Math.min(Math.max(Number(args.depth) || 5, 1), 8)
517
+ const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
518
+ let sftp
519
+ try {
520
+ sftp = await pool.sftp()
521
+ } catch (err) {
522
+ return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
523
+ }
524
+ const { files, touched } = await syncTree(sftp, p, local, depth, maxFiles)
525
+ return { text: `Downloaded ${files} file(s) from ${p} → ${local}${files >= maxFiles ? ' (hit download cap)' : ''}.` }
526
+ },
527
+ }),
528
+
529
+ defineTool({
530
+ name: 'rw_push',
531
+ description:
532
+ 'Upload the local mirror of the current remote workspace back to the remote host over SFTP (bounded). Use after editing files in the local mirror so the remote reflects your changes (bidirectional sync).',
533
+ parameters: {
534
+ maxFiles: { type: 'integer', description: 'Max files to upload (default 500)' },
535
+ },
536
+ output: {
537
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
538
+ render: (_a, a) => [{ type: 'text', text: a.text }],
539
+ },
540
+ async execute(args) {
541
+ const p = wsPath()
542
+ if (!p) throw new Error('rw_push: no remote workspace set — call rw_pick_workspace first')
543
+ const local = mirrorDirFor(p, config.host, config.username, config.port)
544
+ if (!existsSync(local)) throw new Error(`rw_push: local mirror does not exist — run rw_sync first (${local})`)
545
+ const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
546
+ let sftp
547
+ try {
548
+ sftp = await pool.sftp()
549
+ } catch (err) {
550
+ return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
551
+ }
552
+ const { files } = await pushTree(sftp, local, p, p, maxFiles)
553
+ return { text: `Uploaded ${files} file(s) from ${local} → ${p}.` }
369
554
  },
370
555
  }),
371
556
 
@@ -547,7 +732,31 @@ export async function apply(ctx, config) {
547
732
  const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
548
733
  if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
549
734
  config.workspace = p
550
- return sendJson(res, 200, { ok: true, workspace: p, ...status() })
735
+ const local = ensureMirror(p, config.host, config.username, config.port)
736
+ return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
737
+ } catch (err) {
738
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
739
+ }
740
+ },
741
+ },
742
+ {
743
+ kind: 'exact',
744
+ path: '/dsh-remote/mirror',
745
+ handler: async (req, res) => {
746
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
747
+ try {
748
+ const payload = JSON.parse((await readBody(req)) || '{}')
749
+ const p = normalizeRemotePath(String(payload.path || ''))
750
+ if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
751
+ if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
752
+ // optional: verify it's a directory over SSH when connected
753
+ if (pool.client) {
754
+ const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
755
+ if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
756
+ }
757
+ const local = ensureMirror(p, config.host, config.username, config.port)
758
+ config.workspace = p
759
+ return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
551
760
  } catch (err) {
552
761
  return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
553
762
  }
package/package.json CHANGED
@@ -1,49 +1,49 @@
1
- {
2
- "name": "dsh-remote",
3
- "version": "0.2.2",
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
- "keywords": [
6
- "deepseek-harness",
7
- "dsh",
8
- "dsh-plugin",
9
- "remote",
10
- "ssh",
11
- "tunnel",
12
- "plugin"
13
- ],
14
- "type": "module",
15
- "main": "lib/index.js",
16
- "exports": {
17
- ".": "./lib/index.js",
18
- "./client": "./lib/client.js",
19
- "./package.json": "./package.json",
20
- "./cordis.patch.yml": "./cordis.patch.yml"
21
- },
22
- "files": [
23
- "lib",
24
- "cordis.patch.yml"
25
- ],
26
- "license": "MIT",
27
- "dsh": {
28
- "bundle": {
29
- "patch": "./cordis.patch.yml"
30
- },
31
- "client": {
32
- "inject": [
33
- "@deepseek-ai/dsh-client-runtime"
34
- ],
35
- "platform": "web"
36
- }
37
- },
38
- "peerDependencies": {
39
- "@deepseek-ai/dsh-commands": "^0.1.0-rc.6",
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",
43
- "@deepseek-ai/cordis": "^4.0.1"
44
- },
45
- "dependencies": {
46
- "ssh2": "^1.16.0",
47
- "@deepseek-ai/schemastery": "^3.18.1"
48
- }
49
- }
1
+ {
2
+ "name": "dsh-remote",
3
+ "version": "0.3.1",
4
+ "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password or key), pick a remote workspace, operate on it with rw_pick_workspace / rw_list_dir / rw_read_file / rw_exec / rw_sync tools, and mirror it to a real local directory (SFTP) so the DSH native workspace can adopt it.",
5
+ "keywords": [
6
+ "deepseek-harness",
7
+ "dsh",
8
+ "dsh-plugin",
9
+ "remote",
10
+ "ssh",
11
+ "tunnel",
12
+ "plugin"
13
+ ],
14
+ "type": "module",
15
+ "main": "lib/index.js",
16
+ "exports": {
17
+ ".": "./lib/index.js",
18
+ "./client": "./lib/client.js",
19
+ "./package.json": "./package.json",
20
+ "./cordis.patch.yml": "./cordis.patch.yml"
21
+ },
22
+ "files": [
23
+ "lib",
24
+ "cordis.patch.yml"
25
+ ],
26
+ "license": "MIT",
27
+ "dsh": {
28
+ "bundle": {
29
+ "patch": "./cordis.patch.yml"
30
+ },
31
+ "client": {
32
+ "inject": [
33
+ "@deepseek-ai/dsh-client-runtime"
34
+ ],
35
+ "platform": "web"
36
+ }
37
+ },
38
+ "peerDependencies": {
39
+ "@deepseek-ai/dsh-commands": "^0.1.0-rc.6",
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",
43
+ "@deepseek-ai/cordis": "^4.0.1"
44
+ },
45
+ "dependencies": {
46
+ "ssh2": "^1.16.0",
47
+ "@deepseek-ai/schemastery": "^3.18.1"
48
+ }
49
+ }