dsh-remote-sudo 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +184 -2
- package/client.js +367 -0
- package/cordis.patch.yml +3 -0
- package/docs/panel.png +0 -0
- package/icon.svg +6 -0
- package/index.js +550 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +61 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-remote-sudo contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,185 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-remote-sudo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
DSH 的远端 sudo 密码面板:在输入框位置弹出,密码只走 stdin,不进对话、工具参数、环境变量或磁盘。
|
|
6
|
+
|
|
7
|
+
A composer-hosted sudo password prompt for DeepSeek Harness: the password goes to the remote `sudo` over stdin and never enters the transcript, tool arguments, environment, or disk.
|
|
8
|
+
|
|
9
|
+
[中文](#中文) · [English](#english)
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 中文
|
|
16
|
+
|
|
17
|
+
### 功能
|
|
18
|
+
|
|
19
|
+
- **composer 面板**:接管输入框,显示主机、命令、密码;Enter 提交,Esc 取消。
|
|
20
|
+
- **单一缓存开关**:`passwordTtlMs: 0` 每次都要密码;正整数时密码只在宿主内存保留该时长。
|
|
21
|
+
- **不依赖 sudo ticket**:每次执行后 `sudo -k`,不假设 ticket 能跨 ssh 复用。
|
|
22
|
+
- **自动探测 sudo**:先试常见绝对路径,再用 `command -v sudo` 兜底。
|
|
23
|
+
- **中英文**:跟随 DSH 语言;root 命令的 stdin 固定 `/dev/null`。
|
|
24
|
+
|
|
25
|
+
### 安装
|
|
26
|
+
|
|
27
|
+
**从 npm(推荐)**:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
dsh plugin --profile desktop add dsh-remote-sudo
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**从 GitHub**:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
dsh plugin --profile desktop add github:seudut/dsh-remote-sudo#v0.1.0
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
或在插件管理器里选“从 Git 仓库安装”,填 `github:seudut/dsh-remote-sudo#v0.1.0`。
|
|
40
|
+
|
|
41
|
+
然后在 profile 的 `cordis.patch.yml` 加入:
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
- insert:
|
|
45
|
+
- id: sudo-askpass
|
|
46
|
+
name: 'dsh-remote-sudo'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
重启 DSH。前置条件:`ssh <host>` 能密钥登录。
|
|
50
|
+
|
|
51
|
+
插件管理器安装会同时处理 bundle 选择;命令行安装需要手动加这一行。本地开发时,把 `github:...` 换成插件目录的绝对路径即可。
|
|
52
|
+
|
|
53
|
+
### 配置
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
config:
|
|
57
|
+
passwordTtlMs: 0 # 0 = 每次都要密码;正整数 = 内存缓存毫秒数
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
| 键 | 默认 | 说明 |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `passwordTtlMs` | `0` | `0` = 每次都要密码;正整数 = 密码在宿主内存保留的毫秒数 |
|
|
63
|
+
|
|
64
|
+
### 使用
|
|
65
|
+
|
|
66
|
+
让 agent 调用 `remote_sudo`:
|
|
67
|
+
|
|
68
|
+
> 用 `remote_sudo` 在 aliyun 上执行 `apt-get update`
|
|
69
|
+
|
|
70
|
+
| 参数 | 必填 | 说明 |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `host` | 是 | `~/.ssh/config` 别名 |
|
|
73
|
+
| `command` | 是 | 远端 root 命令 |
|
|
74
|
+
| `description` | 否 | 面板里的短标签 |
|
|
75
|
+
|
|
76
|
+
需要密码时弹面板;`passwordTtlMs` 内不重复弹;取消/停止会结束调用。
|
|
77
|
+
|
|
78
|
+
### 安全边界
|
|
79
|
+
|
|
80
|
+
- **能挡**:argv、环境变量、shell history、工具参数、对话记录、远端磁盘、SSH 明文传输。
|
|
81
|
+
- **挡不住**:浏览器插件域(任何 client 插件都能读输入)、宿主内存、本机回环 HTTP、被入侵的远端、JS 内存残留、单因子。
|
|
82
|
+
- **建议**:专用 sudo 密码;Web UI 只绑 loopback 或上 HTTPS;需要更强隔离时用原生 askpass、Touch ID 或 scoped `NOPASSWD`。
|
|
83
|
+
|
|
84
|
+
### 限制
|
|
85
|
+
|
|
86
|
+
- 只支持 SSH 远端,不支持本机 root。
|
|
87
|
+
- 没有内置超时:密码等待和命令执行都会一直阻塞,直到回答/取消/ssh 结束。
|
|
88
|
+
- 取消/停止只杀本地 ssh,远端进程不保证停止;需要硬上限时在远端命令里用 `timeout`。
|
|
89
|
+
- 同时只展示一个 prompt;远端需 POSIX shell + sudo。
|
|
90
|
+
|
|
91
|
+
### 开发
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
npm test
|
|
95
|
+
node test/real-host-probe.mjs # 手动:真实主机 + 错误密码验证链路
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## English
|
|
101
|
+
|
|
102
|
+
### Features
|
|
103
|
+
|
|
104
|
+
- **Composer panel**: takes over the input box with host, command, and password; Enter submits, Esc cancels.
|
|
105
|
+
- **One cache knob**: `passwordTtlMs: 0` asks every call; a positive value keeps the password in host memory for that window.
|
|
106
|
+
- **No sudo ticket reuse**: runs `sudo -k` after every command and never assumes a no-TTY ticket survives across ssh.
|
|
107
|
+
- **Auto-detect sudo**: common absolute paths first, then `command -v sudo`.
|
|
108
|
+
- **Bilingual**: follows the DSH locale; the root command's stdin is always `/dev/null`.
|
|
109
|
+
|
|
110
|
+
### Install
|
|
111
|
+
|
|
112
|
+
**From npm (recommended)**:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
dsh plugin --profile desktop add dsh-remote-sudo
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**From GitHub**:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
dsh plugin --profile desktop add github:seudut/dsh-remote-sudo#v0.1.0
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Or use the plugin manager's "install from Git repository" with `github:seudut/dsh-remote-sudo#v0.1.0`.
|
|
125
|
+
|
|
126
|
+
Then add this row to the profile's `cordis.patch.yml`:
|
|
127
|
+
|
|
128
|
+
```yaml
|
|
129
|
+
- insert:
|
|
130
|
+
- id: sudo-askpass
|
|
131
|
+
name: 'dsh-remote-sudo'
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Restart DSH. Requires key-based `ssh <host>` access.
|
|
135
|
+
|
|
136
|
+
The plugin manager also selects the bundle; a CLI install needs this row manually. For local development, replace the `github:...` spec with the absolute path of the checkout.
|
|
137
|
+
|
|
138
|
+
### Configuration
|
|
139
|
+
|
|
140
|
+
```yaml
|
|
141
|
+
config:
|
|
142
|
+
passwordTtlMs: 0 # 0 = ask every call; positive = in-memory TTL in ms
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| Key | Default | Meaning |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `passwordTtlMs` | `0` | `0` asks every call; a positive value is the in-memory password TTL in milliseconds |
|
|
148
|
+
|
|
149
|
+
### Usage
|
|
150
|
+
|
|
151
|
+
Ask the agent to call `remote_sudo`:
|
|
152
|
+
|
|
153
|
+
> Use `remote_sudo` to run `apt-get update` on aliyun
|
|
154
|
+
|
|
155
|
+
| Argument | Required | Meaning |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `host` | yes | SSH alias from `~/.ssh/config` |
|
|
158
|
+
| `command` | yes | root command to run remotely |
|
|
159
|
+
| `description` | no | short label shown in the panel |
|
|
160
|
+
|
|
161
|
+
The panel appears when a password is needed; within `passwordTtlMs` it does not ask again. Cancel/stop ends the call.
|
|
162
|
+
|
|
163
|
+
### Security
|
|
164
|
+
|
|
165
|
+
- **Protected**: argv, environment, shell history, tool arguments, transcript, remote disk, plaintext transport.
|
|
166
|
+
- **Not protected**: the browser plugin realm (any client plugin can read the input), host memory, loopback HTTP, a compromised remote, JS memory residue, a single factor.
|
|
167
|
+
- **Recommendations**: a dedicated sudo password; bind the Web UI to loopback or use HTTPS; for stronger isolation use a native askpass, Touch ID, or scoped `NOPASSWD`.
|
|
168
|
+
|
|
169
|
+
### Limitations
|
|
170
|
+
|
|
171
|
+
- Remote SSH only; no local root.
|
|
172
|
+
- No built-in timeout: both the password wait and the command run block until answered, cancelled, or ssh exits.
|
|
173
|
+
- Cancel/stop kills the local ssh; the remote process is not guaranteed to stop. Use `timeout` in the remote command for a hard limit.
|
|
174
|
+
- One prompt at a time; the remote must be POSIX with sudo.
|
|
175
|
+
|
|
176
|
+
### Development
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npm test
|
|
180
|
+
node test/real-host-probe.mjs # manual: real host + wrong password
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT
|
package/client.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-remote-sudo — Client half.
|
|
3
|
+
*
|
|
4
|
+
* Long-polls the Host route for a pending sudo prompt, publishes it as a
|
|
5
|
+
* `uiSession` pending interaction, and takes over `conversation.composer`
|
|
6
|
+
* exactly like the approval panel: the prompt replaces the composer while it
|
|
7
|
+
* is pending. The password travels browser → Host over the same authenticated
|
|
8
|
+
* origin; it never enters the session log or a tool call.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: 'dsh-remote-sudo',
|
|
13
|
+
factory(require) {
|
|
14
|
+
const React = require('react')
|
|
15
|
+
const h = React.createElement
|
|
16
|
+
const { useCallback, useEffect, useRef, useState } = React
|
|
17
|
+
|
|
18
|
+
const BASE = '/_dsh/dsh-remote-sudo'
|
|
19
|
+
const POLL_WAIT_MS = 20000
|
|
20
|
+
const RETRY_DELAY_MS = 2000
|
|
21
|
+
const KIND = 'sudo-password'
|
|
22
|
+
const NS = 'dsh-remote-sudo'
|
|
23
|
+
|
|
24
|
+
/** Simplified Chinese dictionary. */
|
|
25
|
+
const zh = {
|
|
26
|
+
aria: 'sudo 授权',
|
|
27
|
+
strip: '需要 sudo 密码',
|
|
28
|
+
headline: '在 {host} 上以 root 执行',
|
|
29
|
+
placeholder: 'sudo 密码',
|
|
30
|
+
cancel: '取消',
|
|
31
|
+
run: '执行',
|
|
32
|
+
running: '执行中…',
|
|
33
|
+
'error.expired': '这次授权已过期,请重新发起',
|
|
34
|
+
'error.empty': '请输入密码',
|
|
35
|
+
'error.failed': '提交失败,请重试',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** English dictionary. */
|
|
39
|
+
const en = {
|
|
40
|
+
aria: 'sudo authorization',
|
|
41
|
+
strip: 'sudo password required',
|
|
42
|
+
headline: 'Run as root on {host}',
|
|
43
|
+
placeholder: 'sudo password',
|
|
44
|
+
cancel: 'Cancel',
|
|
45
|
+
run: 'Run',
|
|
46
|
+
running: 'Running…',
|
|
47
|
+
'error.expired': 'This authorization expired. Start it again.',
|
|
48
|
+
'error.empty': 'Enter a password.',
|
|
49
|
+
'error.failed': 'Could not submit. Try again.',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const S = {
|
|
53
|
+
root: {
|
|
54
|
+
display: 'flex',
|
|
55
|
+
flexDirection: 'column',
|
|
56
|
+
alignItems: 'center',
|
|
57
|
+
padding: '8px calc(var(--dsh-composer-side-clearance, 0px) + 16px) 12px',
|
|
58
|
+
},
|
|
59
|
+
card: {
|
|
60
|
+
width: '100%',
|
|
61
|
+
maxWidth: 'var(--dsh-chat-content-width, 760px)',
|
|
62
|
+
overflow: 'hidden',
|
|
63
|
+
border: '1px solid var(--dsw-alias-state-warn-secondary, rgba(214, 158, 46, 0.35))',
|
|
64
|
+
borderRadius: 'var(--dsw-radius-xl, 14px)',
|
|
65
|
+
background: 'var(--dsw-specific-input-major, var(--dsw-alias-bg-layer-2, #1e1e22))',
|
|
66
|
+
boxShadow: 'var(--dsw-shadow-lv2, 0 8px 24px rgba(0, 0, 0, 0.25))',
|
|
67
|
+
color: 'var(--dsw-alias-label-primary, #f3f3f3)',
|
|
68
|
+
font: '14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
69
|
+
},
|
|
70
|
+
strip: {
|
|
71
|
+
display: 'flex',
|
|
72
|
+
alignItems: 'center',
|
|
73
|
+
gap: '8px',
|
|
74
|
+
padding: '10px 16px',
|
|
75
|
+
background: 'var(--dsw-alias-state-warn-tertiary, rgba(214, 158, 46, 0.12))',
|
|
76
|
+
color: 'var(--dsw-alias-state-warn-primary, #d69e2e)',
|
|
77
|
+
fontSize: '13px',
|
|
78
|
+
lineHeight: '18px',
|
|
79
|
+
},
|
|
80
|
+
dot: {
|
|
81
|
+
width: '6px',
|
|
82
|
+
height: '6px',
|
|
83
|
+
borderRadius: '50%',
|
|
84
|
+
background: 'currentColor',
|
|
85
|
+
flexShrink: 0,
|
|
86
|
+
},
|
|
87
|
+
body: {
|
|
88
|
+
display: 'flex',
|
|
89
|
+
flexDirection: 'column',
|
|
90
|
+
gap: '8px',
|
|
91
|
+
padding: '12px 16px 0',
|
|
92
|
+
maxHeight: 'var(--dsh-composer-text-max-height, 40vh)',
|
|
93
|
+
overflowY: 'auto',
|
|
94
|
+
},
|
|
95
|
+
headline: {
|
|
96
|
+
fontSize: '15px',
|
|
97
|
+
fontWeight: 500,
|
|
98
|
+
lineHeight: '24px',
|
|
99
|
+
},
|
|
100
|
+
command: {
|
|
101
|
+
fontFamily: 'var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
102
|
+
fontSize: '13px',
|
|
103
|
+
lineHeight: '20px',
|
|
104
|
+
color: 'var(--dsw-alias-label-tertiary, #9a9aa3)',
|
|
105
|
+
wordBreak: 'break-all',
|
|
106
|
+
whiteSpace: 'pre-wrap',
|
|
107
|
+
},
|
|
108
|
+
description: {
|
|
109
|
+
fontSize: '12px',
|
|
110
|
+
lineHeight: '18px',
|
|
111
|
+
color: 'var(--dsw-alias-label-secondary, #b9b9c0)',
|
|
112
|
+
},
|
|
113
|
+
input: {
|
|
114
|
+
width: '100%',
|
|
115
|
+
boxSizing: 'border-box',
|
|
116
|
+
padding: '8px 12px',
|
|
117
|
+
borderRadius: 'var(--dsw-radius-md, 8px)',
|
|
118
|
+
border: '1px solid var(--dsw-alias-border-l3, rgba(127, 127, 127, 0.35))',
|
|
119
|
+
background: 'var(--dsw-alias-bg-base, rgba(0, 0, 0, 0.2))',
|
|
120
|
+
color: 'inherit',
|
|
121
|
+
fontSize: '14px',
|
|
122
|
+
outline: 'none',
|
|
123
|
+
},
|
|
124
|
+
error: {
|
|
125
|
+
color: 'var(--dsw-alias-state-error-primary, #e5484d)',
|
|
126
|
+
fontSize: '12px',
|
|
127
|
+
},
|
|
128
|
+
actionRow: {
|
|
129
|
+
display: 'flex',
|
|
130
|
+
justifyContent: 'flex-end',
|
|
131
|
+
gap: '8px',
|
|
132
|
+
padding: '14px 16px',
|
|
133
|
+
},
|
|
134
|
+
reject: {
|
|
135
|
+
padding: '6px 14px',
|
|
136
|
+
borderRadius: 'var(--dsw-radius-md, 8px)',
|
|
137
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, 0.3))',
|
|
138
|
+
background: 'transparent',
|
|
139
|
+
color: 'var(--dsw-alias-label-secondary, #b9b9c0)',
|
|
140
|
+
fontSize: '13px',
|
|
141
|
+
cursor: 'pointer',
|
|
142
|
+
},
|
|
143
|
+
accept: {
|
|
144
|
+
padding: '6px 14px',
|
|
145
|
+
borderRadius: 'var(--dsw-radius-md, 8px)',
|
|
146
|
+
border: '1px solid transparent',
|
|
147
|
+
background: 'var(--dsw-alias-button-primary-fill, #3b82f6)',
|
|
148
|
+
color: 'var(--dsw-alias-label-primary-foreground, #fff)',
|
|
149
|
+
fontSize: '13px',
|
|
150
|
+
fontWeight: 600,
|
|
151
|
+
cursor: 'pointer',
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One published prompt; the composer panel drives answer/cancel. */
|
|
156
|
+
class SudoPrompt {
|
|
157
|
+
constructor(value) {
|
|
158
|
+
this.kind = KIND
|
|
159
|
+
this.key = value.id
|
|
160
|
+
this.id = value.id
|
|
161
|
+
this.host = value.host
|
|
162
|
+
this.command = value.command
|
|
163
|
+
this.description = value.description
|
|
164
|
+
this.sessionId = value.sessionId
|
|
165
|
+
this.settled = false
|
|
166
|
+
this.completion = new Promise((resolve) => {
|
|
167
|
+
this.resolveCompletion = resolve
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
settle() {
|
|
172
|
+
if (this.settled) return
|
|
173
|
+
this.settled = true
|
|
174
|
+
this.resolveCompletion()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async answer(password) {
|
|
178
|
+
if (this.settled) return
|
|
179
|
+
const response = await fetch(`${BASE}/answer`, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: { 'content-type': 'application/json' },
|
|
182
|
+
body: JSON.stringify({ id: this.id, password }),
|
|
183
|
+
})
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
const body = await response.json().catch(() => ({}))
|
|
186
|
+
throw new Error(typeof body.error === 'string' ? body.error : `HTTP ${response.status}`)
|
|
187
|
+
}
|
|
188
|
+
this.settle()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async cancel() {
|
|
192
|
+
if (this.settled) return
|
|
193
|
+
try {
|
|
194
|
+
await fetch(`${BASE}/cancel`, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: { 'content-type': 'application/json' },
|
|
197
|
+
body: JSON.stringify({ id: this.id }),
|
|
198
|
+
})
|
|
199
|
+
} catch {
|
|
200
|
+
// The Host may already have dropped the prompt; cancelling is best effort.
|
|
201
|
+
}
|
|
202
|
+
this.settle()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function SudoPromptPanel(props) {
|
|
207
|
+
const prompt = props.matched
|
|
208
|
+
const t = typeof props.t === 'function' ? props.t : (key) => key
|
|
209
|
+
const [password, setPassword] = useState('')
|
|
210
|
+
const [busy, setBusy] = useState(false)
|
|
211
|
+
const [error, setError] = useState(null)
|
|
212
|
+
const inputRef = useRef(null)
|
|
213
|
+
const rootRef = useRef(null)
|
|
214
|
+
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (inputRef.current !== null) inputRef.current.focus()
|
|
217
|
+
}, [])
|
|
218
|
+
|
|
219
|
+
const describeError = useCallback((cause) => {
|
|
220
|
+
const message = cause instanceof Error ? cause.message : String(cause)
|
|
221
|
+
if (message.includes('no pending prompt')) return t('error.expired')
|
|
222
|
+
if (message.includes('password required')) return t('error.empty')
|
|
223
|
+
return t('error.failed')
|
|
224
|
+
}, [t])
|
|
225
|
+
|
|
226
|
+
const cancel = useCallback(async () => {
|
|
227
|
+
if (busy) return
|
|
228
|
+
setBusy(true)
|
|
229
|
+
setError(null)
|
|
230
|
+
try {
|
|
231
|
+
await prompt.cancel()
|
|
232
|
+
} catch (cause) {
|
|
233
|
+
setError(describeError(cause))
|
|
234
|
+
} finally {
|
|
235
|
+
setBusy(false)
|
|
236
|
+
}
|
|
237
|
+
}, [prompt, busy, describeError])
|
|
238
|
+
|
|
239
|
+
const submit = useCallback(async () => {
|
|
240
|
+
if (busy || password.length === 0) return
|
|
241
|
+
setBusy(true)
|
|
242
|
+
setError(null)
|
|
243
|
+
try {
|
|
244
|
+
await prompt.answer(password)
|
|
245
|
+
} catch (cause) {
|
|
246
|
+
setError(describeError(cause))
|
|
247
|
+
} finally {
|
|
248
|
+
setBusy(false)
|
|
249
|
+
}
|
|
250
|
+
}, [prompt, busy, password, describeError])
|
|
251
|
+
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
const onKeyDown = (event) => {
|
|
254
|
+
if (event.key !== 'Escape') return
|
|
255
|
+
const root = rootRef.current
|
|
256
|
+
if (root !== null && !root.contains(document.activeElement)) return
|
|
257
|
+
event.preventDefault()
|
|
258
|
+
cancel()
|
|
259
|
+
}
|
|
260
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
261
|
+
return () => window.removeEventListener('keydown', onKeyDown, true)
|
|
262
|
+
}, [cancel])
|
|
263
|
+
|
|
264
|
+
return h(
|
|
265
|
+
'div',
|
|
266
|
+
{ ref: rootRef, style: S.root },
|
|
267
|
+
h(
|
|
268
|
+
'div',
|
|
269
|
+
{ style: S.card, role: 'group', 'aria-label': t('aria') },
|
|
270
|
+
h('div', { style: S.strip }, h('span', { style: S.dot }), t('strip')),
|
|
271
|
+
h(
|
|
272
|
+
'div',
|
|
273
|
+
{ style: S.body },
|
|
274
|
+
h('div', { style: S.headline }, t('headline', { host: prompt.host })),
|
|
275
|
+
h('div', { style: S.command }, prompt.command),
|
|
276
|
+
prompt.description ? h('div', { style: S.description }, prompt.description) : null,
|
|
277
|
+
h('input', {
|
|
278
|
+
ref: inputRef,
|
|
279
|
+
type: 'password',
|
|
280
|
+
value: password,
|
|
281
|
+
autoComplete: 'off',
|
|
282
|
+
spellCheck: false,
|
|
283
|
+
placeholder: t('placeholder'),
|
|
284
|
+
disabled: busy,
|
|
285
|
+
style: S.input,
|
|
286
|
+
onChange: (event) => setPassword(event.target.value),
|
|
287
|
+
onKeyDown: (event) => {
|
|
288
|
+
if (event.key === 'Enter') {
|
|
289
|
+
event.preventDefault()
|
|
290
|
+
submit()
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
}),
|
|
294
|
+
error ? h('div', { style: S.error }, error) : null,
|
|
295
|
+
),
|
|
296
|
+
h(
|
|
297
|
+
'div',
|
|
298
|
+
{ style: S.actionRow },
|
|
299
|
+
h('button', { type: 'button', style: S.reject, disabled: busy, onClick: cancel }, t('cancel')),
|
|
300
|
+
h(
|
|
301
|
+
'button',
|
|
302
|
+
{ type: 'button', style: S.accept, disabled: busy || password.length === 0, onClick: submit },
|
|
303
|
+
busy ? t('running') : t('run'),
|
|
304
|
+
),
|
|
305
|
+
),
|
|
306
|
+
),
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
inject: ['locale', 'slots', 'uiSession'],
|
|
312
|
+
apply(ctx) {
|
|
313
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-remote-sudo: dictionaries')
|
|
314
|
+
const publish = ctx.uiSession.registerPendingInteraction((pending) =>
|
|
315
|
+
pending.kind === KIND ? 3 : 0,
|
|
316
|
+
)
|
|
317
|
+
const controller = new AbortController()
|
|
318
|
+
let active = null
|
|
319
|
+
|
|
320
|
+
async function poll() {
|
|
321
|
+
while (!controller.signal.aborted) {
|
|
322
|
+
if (active !== null) {
|
|
323
|
+
await active.completion
|
|
324
|
+
active = null
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
const response = await fetch(`${BASE}/pending?wait=${POLL_WAIT_MS}`, {
|
|
329
|
+
signal: controller.signal,
|
|
330
|
+
headers: { accept: 'application/json' },
|
|
331
|
+
cache: 'no-store',
|
|
332
|
+
})
|
|
333
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
334
|
+
const value = await response.json()
|
|
335
|
+
if (value !== null && typeof value === 'object' && typeof value.id === 'string') {
|
|
336
|
+
const sessionId = typeof value.sessionId === 'string'
|
|
337
|
+
? value.sessionId
|
|
338
|
+
: ctx.uiSession.current?.value?.key
|
|
339
|
+
const prompt = new SudoPrompt({ ...value, sessionId })
|
|
340
|
+
active = prompt
|
|
341
|
+
const remove = publish(prompt, () => prompt.settle())
|
|
342
|
+
prompt.completion.then(() => remove())
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
if (controller.signal.aborted) return
|
|
346
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS))
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
poll()
|
|
352
|
+
|
|
353
|
+
ctx.effect(() => () => {
|
|
354
|
+
controller.abort()
|
|
355
|
+
}, 'dsh-remote-sudo: pending poll')
|
|
356
|
+
|
|
357
|
+
ctx.slots.inject('conversation.composer', () => ctx.slots.register({
|
|
358
|
+
name: 'conversation.composer',
|
|
359
|
+
priority: 2,
|
|
360
|
+
locale: NS,
|
|
361
|
+
select: ({ pendingInteraction }) =>
|
|
362
|
+
pendingInteraction !== undefined && pendingInteraction.kind === KIND ? pendingInteraction : null,
|
|
363
|
+
}, SudoPromptPanel))
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
})
|
package/cordis.patch.yml
ADDED
package/docs/panel.png
ADDED
|
Binary file
|
package/icon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Remote sudo password">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#1f6feb"/>
|
|
3
|
+
<path d="M32 12l16 6v12c0 10-6.6 18.1-16 22-9.4-3.9-16-12-16-22V18l16-6z" fill="#fff" opacity="0.95"/>
|
|
4
|
+
<circle cx="32" cy="28" r="5" fill="#1f6feb"/>
|
|
5
|
+
<rect x="30" y="31" width="4" height="9" rx="2" fill="#1f6feb"/>
|
|
6
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-remote-sudo — Host half.
|
|
3
|
+
*
|
|
4
|
+
* Registers the `remote_sudo` tool and the `/_dsh/dsh-remote-sudo/*` route the
|
|
5
|
+
* Client half uses to fetch a pending password prompt and submit the password.
|
|
6
|
+
*
|
|
7
|
+
* Password handling: the password lives only in this process's memory and in
|
|
8
|
+
* the ssh child's stdin. It is never placed in the tool call, the session log,
|
|
9
|
+
* the tool result, the environment, argv, or a file. The remote command runs
|
|
10
|
+
* with stdin redirected to /dev/null so a password that sudo did not need can
|
|
11
|
+
* never be read by the command.
|
|
12
|
+
*
|
|
13
|
+
* Caching: every call asks for the password by default. When `passwordTtlMs`
|
|
14
|
+
* is positive, the host keeps the password in memory for that long and reuses
|
|
15
|
+
* it, so repeated root commands inside the window do not prompt again. This is
|
|
16
|
+
* only an in-memory cache: the remote sudo timestamp is always invalidated
|
|
17
|
+
* with `sudo -k` after each command, because no-TTY tickets are not assumed to
|
|
18
|
+
* survive across ssh invocations.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn } from 'node:child_process'
|
|
22
|
+
import { randomUUID } from 'node:crypto'
|
|
23
|
+
|
|
24
|
+
const ROUTE_PREFIX = '/_dsh/dsh-remote-sudo'
|
|
25
|
+
const MAX_BODY_BYTES = 16 * 1024
|
|
26
|
+
const MAX_OUTPUT_CHARS = 20000
|
|
27
|
+
const INVALIDATE_TIMEOUT_MS = 15000
|
|
28
|
+
const SUDO_CANDIDATES = [
|
|
29
|
+
'/usr/bin/sudo',
|
|
30
|
+
'/bin/sudo',
|
|
31
|
+
'/usr/local/bin/sudo',
|
|
32
|
+
'/opt/homebrew/bin/sudo',
|
|
33
|
+
'/opt/local/bin/sudo',
|
|
34
|
+
'/run/current-system/sw/bin/sudo',
|
|
35
|
+
]
|
|
36
|
+
const SUDO_RESOLVER = `SUDO=; for p in ${SUDO_CANDIDATES.join(' ')}; do [ -x "$p" ] && { SUDO=$p; break; }; done; if [ -z "$SUDO" ]; then SUDO=$(command -v sudo 2>/dev/null); case "$SUDO" in /*) ;; *) SUDO= ;; esac; fi; if [ -z "$SUDO" ]; then echo "dsh-remote-sudo: sudo not found; set sudoPath" >&2; exit 127; fi; exec "$SUDO"`
|
|
37
|
+
|
|
38
|
+
export const name = 'dsh-remote-sudo'
|
|
39
|
+
export const inject = ['tools', 'webServer', 'connection']
|
|
40
|
+
|
|
41
|
+
const TOOL_DESCRIPTION = [
|
|
42
|
+
'Run a command with sudo on a remote host over SSH.',
|
|
43
|
+
'DSH opens a password prompt; the password is fed to sudo over stdin and is never stored, logged, or returned.',
|
|
44
|
+
'By default every call asks. Set passwordTtlMs to reuse the password from host memory for a short window; the remote sudo timestamp is always invalidated afterwards.',
|
|
45
|
+
'Use this instead of bash for any remote command that needs root, for example apt-get, dpkg, systemctl on a VPS.',
|
|
46
|
+
'The host argument is an SSH host alias from ~/.ssh/config (for example "aliyun").',
|
|
47
|
+
'SSH must authenticate with a key (BatchMode=yes is passed), so this tool never asks for an SSH password.',
|
|
48
|
+
].join(' ')
|
|
49
|
+
|
|
50
|
+
const SYSTEM_PROMPT_SECTION = [
|
|
51
|
+
'Remote root commands: when a command on another machine needs sudo, call `remote_sudo` with the SSH host alias and the root command.',
|
|
52
|
+
'Do not run `ssh <host> sudo ...` through bash: it has no TTY and cannot receive the password.',
|
|
53
|
+
'The password prompt appears in the DSH UI; never ask the user to paste a password into chat.',
|
|
54
|
+
].join(' ')
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Quote one string for POSIX sh so it survives ssh's remote command parsing.
|
|
58
|
+
* @param {string} value raw command text.
|
|
59
|
+
* @returns {string} single-quoted shell word.
|
|
60
|
+
*/
|
|
61
|
+
function shellQuote(value) {
|
|
62
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Send a JSON body without leaking internals to the browser.
|
|
67
|
+
* @param {import('node:http').ServerResponse} res response.
|
|
68
|
+
* @param {number} status HTTP status.
|
|
69
|
+
* @param {unknown} body JSON-serializable payload.
|
|
70
|
+
*/
|
|
71
|
+
function sendJson(res, status, body) {
|
|
72
|
+
if (res.destroyed || res.writableEnded) return
|
|
73
|
+
const text = JSON.stringify(body)
|
|
74
|
+
res.writeHead(status, {
|
|
75
|
+
'content-type': 'application/json; charset=utf-8',
|
|
76
|
+
'cache-control': 'no-store',
|
|
77
|
+
'content-length': Buffer.byteLength(text),
|
|
78
|
+
})
|
|
79
|
+
res.end(text)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read a bounded request body as UTF-8.
|
|
84
|
+
* @param {import('node:http').IncomingMessage} req request.
|
|
85
|
+
* @param {number} limit maximum bytes.
|
|
86
|
+
* @returns {Promise<string>} decoded body.
|
|
87
|
+
*/
|
|
88
|
+
function readBody(req, limit) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const chunks = []
|
|
91
|
+
let size = 0
|
|
92
|
+
req.on('data', (chunk) => {
|
|
93
|
+
size += chunk.length
|
|
94
|
+
if (size > limit) {
|
|
95
|
+
reject(new Error('request body too large'))
|
|
96
|
+
req.destroy()
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
chunks.push(chunk)
|
|
100
|
+
})
|
|
101
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
102
|
+
req.on('error', reject)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Clamp a numeric option.
|
|
108
|
+
* @param {unknown} value raw config value.
|
|
109
|
+
* @param {number} min lower bound.
|
|
110
|
+
* @param {number} max upper bound.
|
|
111
|
+
* @param {number} fallback value used when the input is not a finite number.
|
|
112
|
+
* @returns {number} sanitized value.
|
|
113
|
+
*/
|
|
114
|
+
function numberOption(value, min, max, fallback) {
|
|
115
|
+
const parsed = typeof value === 'number' ? value : Number(value)
|
|
116
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
117
|
+
return Math.min(Math.max(parsed, min), max)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve the plugin row's config with safe defaults.
|
|
122
|
+
* @param {unknown} config raw row config.
|
|
123
|
+
* @returns {{passwordTtlMs: number, sudoPath: string}} options
|
|
124
|
+
*/
|
|
125
|
+
function readOptions(config) {
|
|
126
|
+
const raw = config !== null && typeof config === 'object' ? config : {}
|
|
127
|
+
const sudoPath = typeof raw.sudoPath === 'string' && raw.sudoPath.length > 0 && /^\/[^\s]+$/.test(raw.sudoPath)
|
|
128
|
+
? raw.sudoPath
|
|
129
|
+
: ''
|
|
130
|
+
return {
|
|
131
|
+
passwordTtlMs: numberOption(raw.passwordTtlMs, 0, 3600000, 0),
|
|
132
|
+
sudoPath,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build the remote command. The command itself runs with stdin from /dev/null:
|
|
138
|
+
* sudo reads the password from the ssh pipe before exec'ing the shell, so a
|
|
139
|
+
* leftover password line can never reach the root command.
|
|
140
|
+
* @param {{sudoPath: string, command: string}} input command parts.
|
|
141
|
+
* @returns {string} remote shell command.
|
|
142
|
+
*/
|
|
143
|
+
function buildRemoteCommand(input) {
|
|
144
|
+
const inner = `exec 0</dev/null; ${input.command}`
|
|
145
|
+
const launcher = input.sudoPath.length > 0 ? input.sudoPath : SUDO_RESOLVER
|
|
146
|
+
return `${launcher} -S -p '' -- sh -c ${shellQuote(inner)}`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Spawn one ssh child with the shared non-interactive options.
|
|
151
|
+
* @param {string} host SSH host alias.
|
|
152
|
+
* @param {string} remote remote command string.
|
|
153
|
+
* @param {import('node:child_process').StdioOptions} stdio stdio wiring.
|
|
154
|
+
* @returns {import('node:child_process').ChildProcess} child.
|
|
155
|
+
*/
|
|
156
|
+
function spawnSsh(host, remote, stdio) {
|
|
157
|
+
return spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', host, remote], { stdio })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Append a chunk while keeping only the tail of the stream.
|
|
162
|
+
* @param {string} current accumulated text.
|
|
163
|
+
* @param {Buffer|string} chunk new data.
|
|
164
|
+
* @returns {string} bounded text.
|
|
165
|
+
*/
|
|
166
|
+
function appendCapped(current, chunk) {
|
|
167
|
+
const next = current + chunk.toString('utf8')
|
|
168
|
+
return next.length > MAX_OUTPUT_CHARS ? next.slice(-MAX_OUTPUT_CHARS) : next
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Format one completed remote invocation for the model.
|
|
173
|
+
* @param {string} host SSH host alias.
|
|
174
|
+
* @param {string} command root command text.
|
|
175
|
+
* @param {{stdout: string, stderr: string, code: number|null, signalName: string|null}} result completed run.
|
|
176
|
+
* @returns {string} tool result.
|
|
177
|
+
*/
|
|
178
|
+
function formatResult(host, command, result) {
|
|
179
|
+
const lines = [`remote_sudo ${host}: ${command}`]
|
|
180
|
+
if (result.stdout.trim().length > 0) lines.push(result.stdout.trimEnd())
|
|
181
|
+
if (result.stderr.trim().length > 0) lines.push('[stderr]', result.stderr.trimEnd())
|
|
182
|
+
lines.push(result.signalName ? `[killed by ${result.signalName}]` : `[exit code: ${result.code}]`)
|
|
183
|
+
return lines.join('\n')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Run one remote sudo command. `password` is written to ssh's stdin and then
|
|
188
|
+
* dropped; it is never included in argv, env, the result, or logs.
|
|
189
|
+
* @param {{host: string, remote: string, password: string, signal?: AbortSignal}} input invocation.
|
|
190
|
+
* @returns {Promise<{stdout: string, stderr: string, code: number|null, signalName: string|null}>} completed run.
|
|
191
|
+
*/
|
|
192
|
+
function runRemoteSudo(input) {
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
let child
|
|
195
|
+
try {
|
|
196
|
+
child = spawnSsh(input.host, input.remote, ['pipe', 'pipe', 'pipe'])
|
|
197
|
+
} catch (error) {
|
|
198
|
+
reject(new Error(`cannot start ssh: ${error instanceof Error ? error.message : String(error)}`))
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let stdout = ''
|
|
203
|
+
let stderr = ''
|
|
204
|
+
let settled = false
|
|
205
|
+
const onAbort = () => {
|
|
206
|
+
child.kill('SIGKILL')
|
|
207
|
+
finish()
|
|
208
|
+
reject(new Error('remote_sudo was aborted'))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function finish() {
|
|
212
|
+
if (settled) return
|
|
213
|
+
settled = true
|
|
214
|
+
input.signal?.removeEventListener('abort', onAbort)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
input.signal?.addEventListener('abort', onAbort, { once: true })
|
|
218
|
+
child.stdout.on('data', (chunk) => {
|
|
219
|
+
stdout = appendCapped(stdout, chunk)
|
|
220
|
+
})
|
|
221
|
+
child.stderr.on('data', (chunk) => {
|
|
222
|
+
stderr = appendCapped(stderr, chunk)
|
|
223
|
+
})
|
|
224
|
+
child.stdin.on('error', () => {
|
|
225
|
+
// ssh may close stdin before the write lands (auth failure); the exit code carries the reason.
|
|
226
|
+
})
|
|
227
|
+
child.on('error', (error) => {
|
|
228
|
+
finish()
|
|
229
|
+
reject(new Error(`cannot start ssh: ${error.message}`))
|
|
230
|
+
})
|
|
231
|
+
child.on('close', (code, signalName) => {
|
|
232
|
+
finish()
|
|
233
|
+
resolve({ stdout, stderr, code, signalName })
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
if (input.password.length > 0) child.stdin.end(`${input.password}\n`)
|
|
237
|
+
else child.stdin.end()
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Invalidate the remote sudo timestamp after every command.
|
|
243
|
+
* @param {{host: string, sudoPath: string}} input invalidation target.
|
|
244
|
+
* @returns {Promise<void>} completion; failures are ignored.
|
|
245
|
+
*/
|
|
246
|
+
function invalidateTicket(input) {
|
|
247
|
+
return new Promise((resolve) => {
|
|
248
|
+
let child
|
|
249
|
+
const launcher = input.sudoPath.length > 0 ? input.sudoPath : SUDO_RESOLVER
|
|
250
|
+
try {
|
|
251
|
+
child = spawnSsh(input.host, `${launcher} -k`, 'ignore')
|
|
252
|
+
} catch {
|
|
253
|
+
resolve()
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
child.kill('SIGKILL')
|
|
258
|
+
resolve()
|
|
259
|
+
}, INVALIDATE_TIMEOUT_MS)
|
|
260
|
+
child.on('close', () => {
|
|
261
|
+
clearTimeout(timer)
|
|
262
|
+
resolve()
|
|
263
|
+
})
|
|
264
|
+
child.on('error', () => {
|
|
265
|
+
clearTimeout(timer)
|
|
266
|
+
resolve()
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Install the tool, the web route, and the prompt section.
|
|
273
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx plugin context.
|
|
274
|
+
* @param {unknown} config row config.
|
|
275
|
+
*/
|
|
276
|
+
export function apply(ctx, config = {}) {
|
|
277
|
+
const options = readOptions(config)
|
|
278
|
+
|
|
279
|
+
/** @type {Map<string, {id: string, host: string, command: string, description?: string, sessionId?: string, createdAt: number, resolve: (password: string) => void, reject: (error: Error) => void}>} */
|
|
280
|
+
const pending = new Map()
|
|
281
|
+
/** @type {Set<() => void>} */
|
|
282
|
+
const waiters = new Set()
|
|
283
|
+
/** @type {Map<string, {value: string, expiresAt: number}>} */
|
|
284
|
+
const cachedPasswords = new Map()
|
|
285
|
+
|
|
286
|
+
function snapshot() {
|
|
287
|
+
const first = pending.values().next().value
|
|
288
|
+
if (first === undefined) return null
|
|
289
|
+
return {
|
|
290
|
+
id: first.id,
|
|
291
|
+
host: first.host,
|
|
292
|
+
command: first.command,
|
|
293
|
+
...(first.description === undefined ? {} : { description: first.description }),
|
|
294
|
+
...(first.sessionId === undefined ? {} : { sessionId: first.sessionId }),
|
|
295
|
+
createdAt: first.createdAt,
|
|
296
|
+
count: pending.size,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function wakeWaiters() {
|
|
301
|
+
for (const wake of [...waiters]) wake()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Resolve with the current or next prompt, or null after the wait.
|
|
306
|
+
* @param {number} ms maximum wait.
|
|
307
|
+
* @param {import('node:http').IncomingMessage} req request used to stop early.
|
|
308
|
+
* @returns {Promise<ReturnType<typeof snapshot>>} prompt snapshot.
|
|
309
|
+
*/
|
|
310
|
+
function waitForPrompt(ms, req) {
|
|
311
|
+
const now = snapshot()
|
|
312
|
+
if (now !== null || ms <= 0) return Promise.resolve(now)
|
|
313
|
+
return new Promise((resolve) => {
|
|
314
|
+
let settled = false
|
|
315
|
+
const finish = () => {
|
|
316
|
+
if (settled) return
|
|
317
|
+
settled = true
|
|
318
|
+
clearTimeout(timer)
|
|
319
|
+
waiters.delete(finish)
|
|
320
|
+
req.removeListener('close', finish)
|
|
321
|
+
resolve(snapshot())
|
|
322
|
+
}
|
|
323
|
+
const timer = setTimeout(finish, ms)
|
|
324
|
+
waiters.add(finish)
|
|
325
|
+
req.once('close', finish)
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Ask the browser for one password and wait for the answer.
|
|
331
|
+
* @param {{host: string, command: string, description?: string, sessionId?: string, signal?: AbortSignal}} request prompt content.
|
|
332
|
+
* @returns {Promise<string>} the submitted password.
|
|
333
|
+
*/
|
|
334
|
+
function requestPassword(request) {
|
|
335
|
+
const id = randomUUID()
|
|
336
|
+
return new Promise((resolve, reject) => {
|
|
337
|
+
const entry = {
|
|
338
|
+
id,
|
|
339
|
+
host: request.host,
|
|
340
|
+
command: request.command,
|
|
341
|
+
...(request.description === undefined ? {} : { description: request.description }),
|
|
342
|
+
...(request.sessionId === undefined ? {} : { sessionId: request.sessionId }),
|
|
343
|
+
createdAt: Date.now(),
|
|
344
|
+
resolve: (value) => {
|
|
345
|
+
pending.delete(id)
|
|
346
|
+
resolve(value)
|
|
347
|
+
},
|
|
348
|
+
reject: (error) => {
|
|
349
|
+
pending.delete(id)
|
|
350
|
+
reject(error)
|
|
351
|
+
},
|
|
352
|
+
}
|
|
353
|
+
pending.set(id, entry)
|
|
354
|
+
wakeWaiters()
|
|
355
|
+
request.signal?.addEventListener('abort', () => {
|
|
356
|
+
entry.reject(new Error('remote_sudo was aborted before the password was entered'))
|
|
357
|
+
}, { once: true })
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function handleRoute(req, res) {
|
|
362
|
+
let method = ''
|
|
363
|
+
try {
|
|
364
|
+
const rejection = typeof ctx.connection.requestRejection === 'function'
|
|
365
|
+
? ctx.connection.requestRejection(req)
|
|
366
|
+
: undefined
|
|
367
|
+
if (rejection !== undefined) {
|
|
368
|
+
sendJson(res, rejection, { error: rejection === 401 ? 'authentication required' : 'request rejected' })
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
373
|
+
if (!url.pathname.startsWith(`${ROUTE_PREFIX}/`)) {
|
|
374
|
+
sendJson(res, 404, { error: 'not found' })
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
method = decodeURIComponent(url.pathname.slice(ROUTE_PREFIX.length + 1))
|
|
378
|
+
|
|
379
|
+
if (method === 'pending') {
|
|
380
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
381
|
+
sendJson(res, 405, { error: 'pending requires GET' })
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
const requested = Number(url.searchParams.get('wait') ?? '0')
|
|
385
|
+
const wait = Number.isFinite(requested) ? Math.min(Math.max(requested, 0), 25000) : 0
|
|
386
|
+
const prompt = await waitForPrompt(wait, req)
|
|
387
|
+
sendJson(res, 200, prompt ?? {})
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (method === 'answer') {
|
|
392
|
+
if (req.method !== 'POST') {
|
|
393
|
+
sendJson(res, 405, { error: 'answer requires POST' })
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
const body = JSON.parse(await readBody(req, MAX_BODY_BYTES) || '{}')
|
|
397
|
+
const id = typeof body?.id === 'string' ? body.id : ''
|
|
398
|
+
const password = typeof body?.password === 'string' ? body.password : ''
|
|
399
|
+
const entry = pending.get(id)
|
|
400
|
+
if (entry === undefined) {
|
|
401
|
+
sendJson(res, 409, { error: 'no pending prompt with that id' })
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
if (password.length === 0) {
|
|
405
|
+
sendJson(res, 400, { error: 'password required' })
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
entry.resolve(password)
|
|
409
|
+
sendJson(res, 200, { ok: true })
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (method === 'cancel') {
|
|
414
|
+
if (req.method !== 'POST') {
|
|
415
|
+
sendJson(res, 405, { error: 'cancel requires POST' })
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
const body = JSON.parse(await readBody(req, MAX_BODY_BYTES) || '{}')
|
|
419
|
+
const id = typeof body?.id === 'string' ? body.id : ''
|
|
420
|
+
const entry = pending.get(id)
|
|
421
|
+
if (entry === undefined) {
|
|
422
|
+
sendJson(res, 409, { error: 'no pending prompt with that id' })
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
entry.reject(new Error('sudo password prompt was cancelled by the user'))
|
|
426
|
+
sendJson(res, 200, { ok: true })
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
sendJson(res, 404, { error: `unknown method: ${method}` })
|
|
431
|
+
} catch (error) {
|
|
432
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
433
|
+
sendJson(res, message === 'request body too large' ? 413 : 400, { error: message })
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
ctx.effect(() => {
|
|
438
|
+
const dispose = ctx.webServer.register({
|
|
439
|
+
kind: 'prefix',
|
|
440
|
+
path: ROUTE_PREFIX,
|
|
441
|
+
handler: handleRoute,
|
|
442
|
+
})
|
|
443
|
+
return () => dispose()
|
|
444
|
+
}, 'dsh-remote-sudo: web route')
|
|
445
|
+
|
|
446
|
+
ctx.effect(() => () => {
|
|
447
|
+
cachedPasswords.clear()
|
|
448
|
+
}, 'dsh-remote-sudo: password cache')
|
|
449
|
+
|
|
450
|
+
ctx.effect(() => () => {
|
|
451
|
+
for (const entry of [...pending.values()]) {
|
|
452
|
+
entry.reject(new Error('dsh-remote-sudo was unloaded'))
|
|
453
|
+
}
|
|
454
|
+
pending.clear()
|
|
455
|
+
wakeWaiters()
|
|
456
|
+
}, 'dsh-remote-sudo: pending prompts')
|
|
457
|
+
|
|
458
|
+
ctx.tools.register({
|
|
459
|
+
name: 'remote_sudo',
|
|
460
|
+
description: TOOL_DESCRIPTION,
|
|
461
|
+
parameters: {
|
|
462
|
+
type: 'object',
|
|
463
|
+
additionalProperties: false,
|
|
464
|
+
properties: {
|
|
465
|
+
host: {
|
|
466
|
+
type: 'string',
|
|
467
|
+
description: 'SSH host alias from ~/.ssh/config, for example "aliyun".',
|
|
468
|
+
},
|
|
469
|
+
command: {
|
|
470
|
+
type: 'string',
|
|
471
|
+
description: 'Command to run as root on the remote host, for example "apt-get update".',
|
|
472
|
+
},
|
|
473
|
+
description: {
|
|
474
|
+
type: 'string',
|
|
475
|
+
description: 'Optional short label shown in the password prompt, for example "更新 apt 索引".',
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
required: ['host', 'command'],
|
|
479
|
+
},
|
|
480
|
+
output: {
|
|
481
|
+
schema: { type: 'string' },
|
|
482
|
+
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
|
483
|
+
},
|
|
484
|
+
async execute(args, exec) {
|
|
485
|
+
const host = String(args.host ?? '').trim()
|
|
486
|
+
const command = String(args.command ?? '').trim()
|
|
487
|
+
if (host.length === 0 || !/^[A-Za-z0-9._@%+:-]+$/.test(host) || host.startsWith('-')) {
|
|
488
|
+
throw new Error('remote_sudo: host must be an SSH host alias (letters, digits, dot, dash, underscore)')
|
|
489
|
+
}
|
|
490
|
+
if (command.length === 0 || command.includes('\u0000')) {
|
|
491
|
+
throw new Error('remote_sudo: command is required')
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const sessionId = typeof exec.agent?.session?.id === 'string' ? exec.agent.session.id : undefined
|
|
495
|
+
|
|
496
|
+
let password = ''
|
|
497
|
+
if (options.passwordTtlMs > 0) {
|
|
498
|
+
const cached = cachedPasswords.get(host)
|
|
499
|
+
if (cached !== undefined && cached.expiresAt > Date.now()) password = cached.value
|
|
500
|
+
}
|
|
501
|
+
if (password.length === 0) {
|
|
502
|
+
password = await requestPassword({
|
|
503
|
+
host,
|
|
504
|
+
command,
|
|
505
|
+
description: typeof args.description === 'string' && args.description.trim().length > 0
|
|
506
|
+
? args.description.trim()
|
|
507
|
+
: undefined,
|
|
508
|
+
sessionId,
|
|
509
|
+
signal: exec.signal,
|
|
510
|
+
})
|
|
511
|
+
if (options.passwordTtlMs > 0) {
|
|
512
|
+
cachedPasswords.set(host, { value: password, expiresAt: Date.now() + options.passwordTtlMs })
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const remote = buildRemoteCommand({ sudoPath: options.sudoPath, command })
|
|
517
|
+
let result
|
|
518
|
+
try {
|
|
519
|
+
result = await runRemoteSudo({
|
|
520
|
+
host,
|
|
521
|
+
remote,
|
|
522
|
+
password,
|
|
523
|
+
signal: exec.signal,
|
|
524
|
+
})
|
|
525
|
+
} finally {
|
|
526
|
+
password = ''
|
|
527
|
+
await invalidateTicket({ host, sudoPath: options.sudoPath })
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (result.code !== 0) {
|
|
531
|
+
// Conservative: any failure drops the cached password so the next call
|
|
532
|
+
// asks again instead of replaying a possibly stale secret.
|
|
533
|
+
cachedPasswords.delete(host)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return formatResult(host, command, result)
|
|
537
|
+
},
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
ctx.inject(['systemPrompt'], (scope) => {
|
|
541
|
+
scope.effect(() => {
|
|
542
|
+
const dispose = scope.systemPrompt.section({
|
|
543
|
+
name: 'tool:remote-sudo',
|
|
544
|
+
order: 10250,
|
|
545
|
+
text: SYSTEM_PROMPT_SECTION,
|
|
546
|
+
})
|
|
547
|
+
return () => dispose()
|
|
548
|
+
}, 'dsh-remote-sudo: prompt section')
|
|
549
|
+
})
|
|
550
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-sudo",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Ask for a sudo password in the DSH composer and run remote root commands over SSH without putting the password in the transcript.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/seudut/dsh-remote-sudo.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/seudut/dsh-remote-sudo#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/seudut/dsh-remote-sudo/issues"
|
|
14
|
+
},
|
|
15
|
+
"main": "index.js",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./index.js",
|
|
18
|
+
"./client": "./client.js",
|
|
19
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
20
|
+
"./locale/*.json": "./locale/*.json",
|
|
21
|
+
"./icon": "./icon.svg",
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"index.js",
|
|
26
|
+
"client.js",
|
|
27
|
+
"cordis.patch.yml",
|
|
28
|
+
"locale/*.json",
|
|
29
|
+
"icon.svg",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"docs/panel.png"
|
|
33
|
+
],
|
|
34
|
+
"keywords": [
|
|
35
|
+
"dsh",
|
|
36
|
+
"dsh-plugin",
|
|
37
|
+
"deepseek-harness",
|
|
38
|
+
"sudo",
|
|
39
|
+
"ssh",
|
|
40
|
+
"askpass",
|
|
41
|
+
"password",
|
|
42
|
+
"remote"
|
|
43
|
+
],
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=22"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node test/host-test.mjs && node test/client-render-test.mjs"
|
|
49
|
+
},
|
|
50
|
+
"dsh": {
|
|
51
|
+
"bundle": {
|
|
52
|
+
"patch": "./cordis.patch.yml"
|
|
53
|
+
},
|
|
54
|
+
"client": {
|
|
55
|
+
"platform": "web",
|
|
56
|
+
"immediately": true,
|
|
57
|
+
"inject": [
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-session"
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|