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 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
- # Temporary Holding Version
1
+ # dsh-remote-sudo
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ![CI](https://github.com/seudut/dsh-remote-sudo/actions/workflows/ci.yml/badge.svg)
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
+ ![DSH composer sudo password panel](docs/panel.png)
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
+ })
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: sudo-askpass
3
+ name: 'dsh-remote-sudo'
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
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Remote sudo password",
4
+ "description": "Prompts for a sudo password in the composer and runs remote root commands over SSH."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "远程 sudo 密码",
4
+ "description": "在输入框位置询问 sudo 密码,通过 SSH 执行远端 root 命令。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "dsh-remote-sudo",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }