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