dsh-remote-sudo 0.1.0 → 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/README.md +8 -6
- package/client.js +99 -23
- package/index.js +61 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ A composer-hosted sudo password prompt for DeepSeek Harness: the password goes t
|
|
|
17
17
|
### 功能
|
|
18
18
|
|
|
19
19
|
- **composer 面板**:接管输入框,显示主机、命令、密码;Enter 提交,Esc 取消。
|
|
20
|
+
- **多端同步**:电脑和手机(dsh-pocket)同时打开时,任意一端提交或取消密码,其他端的面板会立即撤销,不会残留。
|
|
20
21
|
- **单一缓存开关**:`passwordTtlMs: 0` 每次都要密码;正整数时密码只在宿主内存保留该时长。
|
|
21
22
|
- **不依赖 sudo ticket**:每次执行后 `sudo -k`,不假设 ticket 能跨 ssh 复用。
|
|
22
23
|
- **自动探测 sudo**:先试常见绝对路径,再用 `command -v sudo` 兜底。
|
|
@@ -33,10 +34,10 @@ dsh plugin --profile desktop add dsh-remote-sudo
|
|
|
33
34
|
**从 GitHub**:
|
|
34
35
|
|
|
35
36
|
```bash
|
|
36
|
-
dsh plugin --profile desktop add github:seudut/dsh-remote-sudo#v0.1.
|
|
37
|
+
dsh plugin --profile desktop add github:seudut/dsh-remote-sudo#v0.1.1
|
|
37
38
|
```
|
|
38
39
|
|
|
39
|
-
或在插件管理器里选“从 Git 仓库安装”,填 `github:seudut/dsh-remote-sudo#v0.1.
|
|
40
|
+
或在插件管理器里选“从 Git 仓库安装”,填 `github:seudut/dsh-remote-sudo#v0.1.1`。
|
|
40
41
|
|
|
41
42
|
然后在 profile 的 `cordis.patch.yml` 加入:
|
|
42
43
|
|
|
@@ -73,7 +74,7 @@ config:
|
|
|
73
74
|
| `command` | 是 | 远端 root 命令 |
|
|
74
75
|
| `description` | 否 | 面板里的短标签 |
|
|
75
76
|
|
|
76
|
-
需要密码时弹面板;`passwordTtlMs`
|
|
77
|
+
需要密码时弹面板;`passwordTtlMs` 内不重复弹;取消/停止会结束调用。多端同时打开时每端都会看到同一个 prompt,先提交者生效,其余端自动撤销。
|
|
77
78
|
|
|
78
79
|
### 安全边界
|
|
79
80
|
|
|
@@ -102,6 +103,7 @@ node test/real-host-probe.mjs # 手动:真实主机 + 错误密码验证链
|
|
|
102
103
|
### Features
|
|
103
104
|
|
|
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.
|
|
105
107
|
- **One cache knob**: `passwordTtlMs: 0` asks every call; a positive value keeps the password in host memory for that window.
|
|
106
108
|
- **No sudo ticket reuse**: runs `sudo -k` after every command and never assumes a no-TTY ticket survives across ssh.
|
|
107
109
|
- **Auto-detect sudo**: common absolute paths first, then `command -v sudo`.
|
|
@@ -118,10 +120,10 @@ dsh plugin --profile desktop add dsh-remote-sudo
|
|
|
118
120
|
**From GitHub**:
|
|
119
121
|
|
|
120
122
|
```bash
|
|
121
|
-
dsh plugin --profile desktop add github:seudut/dsh-remote-sudo#v0.1.
|
|
123
|
+
dsh plugin --profile desktop add github:seudut/dsh-remote-sudo#v0.1.1
|
|
122
124
|
```
|
|
123
125
|
|
|
124
|
-
Or use the plugin manager's "install from Git repository" with `github:seudut/dsh-remote-sudo#v0.1.
|
|
126
|
+
Or use the plugin manager's "install from Git repository" with `github:seudut/dsh-remote-sudo#v0.1.1`.
|
|
125
127
|
|
|
126
128
|
Then add this row to the profile's `cordis.patch.yml`:
|
|
127
129
|
|
|
@@ -158,7 +160,7 @@ Ask the agent to call `remote_sudo`:
|
|
|
158
160
|
| `command` | yes | root command to run remotely |
|
|
159
161
|
| `description` | no | short label shown in the panel |
|
|
160
162
|
|
|
161
|
-
The panel appears when a password is needed; within `passwordTtlMs` it does not ask again. Cancel/stop ends the call.
|
|
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.
|
|
162
164
|
|
|
163
165
|
### Security
|
|
164
166
|
|
package/client.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-remote-sudo — Client half.
|
|
3
3
|
*
|
|
4
|
-
* Long-polls the Host route for a pending sudo prompt
|
|
5
|
-
* `uiSession` pending interaction, and takes over
|
|
6
|
-
* exactly like the approval panel: the prompt replaces
|
|
7
|
-
* is pending.
|
|
8
|
-
*
|
|
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.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
window.__ModuleLoader__.load({
|
|
@@ -18,6 +21,7 @@ window.__ModuleLoader__.load({
|
|
|
18
21
|
const BASE = '/_dsh/dsh-remote-sudo'
|
|
19
22
|
const POLL_WAIT_MS = 20000
|
|
20
23
|
const RETRY_DELAY_MS = 2000
|
|
24
|
+
const LEGACY_RECHECK_MS = 2000
|
|
21
25
|
const KIND = 'sudo-password'
|
|
22
26
|
const NS = 'dsh-remote-sudo'
|
|
23
27
|
|
|
@@ -154,7 +158,7 @@ window.__ModuleLoader__.load({
|
|
|
154
158
|
|
|
155
159
|
/** One published prompt; the composer panel drives answer/cancel. */
|
|
156
160
|
class SudoPrompt {
|
|
157
|
-
constructor(value) {
|
|
161
|
+
constructor(value, noteHostState) {
|
|
158
162
|
this.kind = KIND
|
|
159
163
|
this.key = value.id
|
|
160
164
|
this.id = value.id
|
|
@@ -162,6 +166,7 @@ window.__ModuleLoader__.load({
|
|
|
162
166
|
this.command = value.command
|
|
163
167
|
this.description = value.description
|
|
164
168
|
this.sessionId = value.sessionId
|
|
169
|
+
this.noteHostState = noteHostState
|
|
165
170
|
this.settled = false
|
|
166
171
|
this.completion = new Promise((resolve) => {
|
|
167
172
|
this.resolveCompletion = resolve
|
|
@@ -181,9 +186,16 @@ window.__ModuleLoader__.load({
|
|
|
181
186
|
headers: { 'content-type': 'application/json' },
|
|
182
187
|
body: JSON.stringify({ id: this.id, password }),
|
|
183
188
|
})
|
|
189
|
+
const body = await response.json().catch(() => null)
|
|
190
|
+
this.noteHostState?.(body)
|
|
184
191
|
if (!response.ok) {
|
|
185
|
-
|
|
186
|
-
|
|
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}`)
|
|
187
199
|
}
|
|
188
200
|
this.settle()
|
|
189
201
|
}
|
|
@@ -191,11 +203,13 @@ window.__ModuleLoader__.load({
|
|
|
191
203
|
async cancel() {
|
|
192
204
|
if (this.settled) return
|
|
193
205
|
try {
|
|
194
|
-
await fetch(`${BASE}/cancel`, {
|
|
206
|
+
const response = await fetch(`${BASE}/cancel`, {
|
|
195
207
|
method: 'POST',
|
|
196
208
|
headers: { 'content-type': 'application/json' },
|
|
197
209
|
body: JSON.stringify({ id: this.id }),
|
|
198
210
|
})
|
|
211
|
+
const body = await response.json().catch(() => null)
|
|
212
|
+
this.noteHostState?.(body)
|
|
199
213
|
} catch {
|
|
200
214
|
// The Host may already have dropped the prompt; cancelling is best effort.
|
|
201
215
|
}
|
|
@@ -316,30 +330,92 @@ window.__ModuleLoader__.load({
|
|
|
316
330
|
)
|
|
317
331
|
const controller = new AbortController()
|
|
318
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
|
+
}
|
|
319
402
|
|
|
320
403
|
async function poll() {
|
|
321
404
|
while (!controller.signal.aborted) {
|
|
322
|
-
if (active !== null) {
|
|
323
|
-
await active.completion
|
|
324
|
-
active = null
|
|
325
|
-
continue
|
|
326
|
-
}
|
|
327
405
|
try {
|
|
328
|
-
const
|
|
406
|
+
const query = `wait=${POLL_WAIT_MS}&since=${revision}&epoch=${encodeURIComponent(epoch ?? '')}`
|
|
407
|
+
const response = await fetch(`${BASE}/pending?${query}`, {
|
|
329
408
|
signal: controller.signal,
|
|
330
409
|
headers: { accept: 'application/json' },
|
|
331
410
|
cache: 'no-store',
|
|
332
411
|
})
|
|
333
412
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
334
413
|
const value = await response.json()
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
active = prompt
|
|
341
|
-
const remove = publish(prompt, () => prompt.settle())
|
|
342
|
-
prompt.completion.then(() => remove())
|
|
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))
|
|
343
419
|
}
|
|
344
420
|
} catch {
|
|
345
421
|
if (controller.signal.aborted) return
|
package/index.js
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Registers the `remote_sudo` tool and the `/_dsh/dsh-remote-sudo/*` route the
|
|
5
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.
|
|
6
12
|
*
|
|
7
13
|
* Password handling: the password lives only in this process's memory and in
|
|
8
14
|
* the ssh child's stdin. It is never placed in the tool call, the session log,
|
|
@@ -117,6 +123,19 @@ function numberOption(value, min, max, fallback) {
|
|
|
117
123
|
return Math.min(Math.max(parsed, min), max)
|
|
118
124
|
}
|
|
119
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
|
+
|
|
120
139
|
/**
|
|
121
140
|
* Resolve the plugin row's config with safe defaults.
|
|
122
141
|
* @param {unknown} config raw row config.
|
|
@@ -282,6 +301,10 @@ export function apply(ctx, config = {}) {
|
|
|
282
301
|
const waiters = new Set()
|
|
283
302
|
/** @type {Map<string, {value: string, expiresAt: number}>} */
|
|
284
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()
|
|
285
308
|
|
|
286
309
|
function snapshot() {
|
|
287
310
|
const first = pending.values().next().value
|
|
@@ -294,6 +317,8 @@ export function apply(ctx, config = {}) {
|
|
|
294
317
|
...(first.sessionId === undefined ? {} : { sessionId: first.sessionId }),
|
|
295
318
|
createdAt: first.createdAt,
|
|
296
319
|
count: pending.size,
|
|
320
|
+
revision,
|
|
321
|
+
epoch,
|
|
297
322
|
}
|
|
298
323
|
}
|
|
299
324
|
|
|
@@ -301,15 +326,34 @@ export function apply(ctx, config = {}) {
|
|
|
301
326
|
for (const wake of [...waiters]) wake()
|
|
302
327
|
}
|
|
303
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
|
+
|
|
304
339
|
/**
|
|
305
340
|
* Resolve with the current or next prompt, or null after the wait.
|
|
306
341
|
* @param {number} ms maximum wait.
|
|
307
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.
|
|
308
349
|
* @returns {Promise<ReturnType<typeof snapshot>>} prompt snapshot.
|
|
309
350
|
*/
|
|
310
|
-
function waitForPrompt(ms, req) {
|
|
311
|
-
const
|
|
312
|
-
|
|
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())
|
|
313
357
|
return new Promise((resolve) => {
|
|
314
358
|
let settled = false
|
|
315
359
|
const finish = () => {
|
|
@@ -342,16 +386,18 @@ export function apply(ctx, config = {}) {
|
|
|
342
386
|
...(request.sessionId === undefined ? {} : { sessionId: request.sessionId }),
|
|
343
387
|
createdAt: Date.now(),
|
|
344
388
|
resolve: (value) => {
|
|
345
|
-
pending.delete(id)
|
|
389
|
+
if (!pending.delete(id)) return
|
|
390
|
+
touch()
|
|
346
391
|
resolve(value)
|
|
347
392
|
},
|
|
348
393
|
reject: (error) => {
|
|
349
|
-
pending.delete(id)
|
|
394
|
+
if (!pending.delete(id)) return
|
|
395
|
+
touch()
|
|
350
396
|
reject(error)
|
|
351
397
|
},
|
|
352
398
|
}
|
|
353
399
|
pending.set(id, entry)
|
|
354
|
-
|
|
400
|
+
touch()
|
|
355
401
|
request.signal?.addEventListener('abort', () => {
|
|
356
402
|
entry.reject(new Error('remote_sudo was aborted before the password was entered'))
|
|
357
403
|
}, { once: true })
|
|
@@ -383,8 +429,10 @@ export function apply(ctx, config = {}) {
|
|
|
383
429
|
}
|
|
384
430
|
const requested = Number(url.searchParams.get('wait') ?? '0')
|
|
385
431
|
const wait = Number.isFinite(requested) ? Math.min(Math.max(requested, 0), 25000) : 0
|
|
386
|
-
const
|
|
387
|
-
|
|
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 })
|
|
388
436
|
return
|
|
389
437
|
}
|
|
390
438
|
|
|
@@ -398,15 +446,15 @@ export function apply(ctx, config = {}) {
|
|
|
398
446
|
const password = typeof body?.password === 'string' ? body.password : ''
|
|
399
447
|
const entry = pending.get(id)
|
|
400
448
|
if (entry === undefined) {
|
|
401
|
-
sendJson(res, 409, { error: 'no pending prompt with that id' })
|
|
449
|
+
sendJson(res, 409, { error: 'no pending prompt with that id', revision, epoch })
|
|
402
450
|
return
|
|
403
451
|
}
|
|
404
452
|
if (password.length === 0) {
|
|
405
|
-
sendJson(res, 400, { error: 'password required' })
|
|
453
|
+
sendJson(res, 400, { error: 'password required', revision, epoch })
|
|
406
454
|
return
|
|
407
455
|
}
|
|
408
456
|
entry.resolve(password)
|
|
409
|
-
sendJson(res, 200, { ok: true })
|
|
457
|
+
sendJson(res, 200, { ok: true, revision, epoch })
|
|
410
458
|
return
|
|
411
459
|
}
|
|
412
460
|
|
|
@@ -419,11 +467,11 @@ export function apply(ctx, config = {}) {
|
|
|
419
467
|
const id = typeof body?.id === 'string' ? body.id : ''
|
|
420
468
|
const entry = pending.get(id)
|
|
421
469
|
if (entry === undefined) {
|
|
422
|
-
sendJson(res, 409, { error: 'no pending prompt with that id' })
|
|
470
|
+
sendJson(res, 409, { error: 'no pending prompt with that id', revision, epoch })
|
|
423
471
|
return
|
|
424
472
|
}
|
|
425
473
|
entry.reject(new Error('sudo password prompt was cancelled by the user'))
|
|
426
|
-
sendJson(res, 200, { ok: true })
|
|
474
|
+
sendJson(res, 200, { ok: true, revision, epoch })
|
|
427
475
|
return
|
|
428
476
|
}
|
|
429
477
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-sudo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"node": ">=22"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
|
-
"test": "node test/host-test.mjs && node test/client-render-test.mjs"
|
|
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
49
|
},
|
|
50
50
|
"dsh": {
|
|
51
51
|
"bundle": {
|