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.
Files changed (4) hide show
  1. package/README.md +8 -6
  2. package/client.js +99 -23
  3. package/index.js +61 -13
  4. 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.0
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.0`。
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.0
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.0`.
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, 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.
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
- const body = await response.json().catch(() => ({}))
186
- throw new Error(typeof body.error === 'string' ? body.error : `HTTP ${response.status}`)
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 response = await fetch(`${BASE}/pending?wait=${POLL_WAIT_MS}`, {
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
- 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())
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 now = snapshot()
312
- if (now !== null || ms <= 0) return Promise.resolve(now)
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
- wakeWaiters()
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 prompt = await waitForPrompt(wait, req)
387
- sendJson(res, 200, prompt ?? {})
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.0",
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": {