dsh-plugin-capabilities 0.1.1 → 0.1.2

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.
@@ -28,7 +28,7 @@ export interface McpInjected {
28
28
  remove(id: string): Promise<{ ok: boolean }>
29
29
  scanImport(): Promise<{ servers: ImportedServerView[]; existing: string[] }>
30
30
  applyImport(items: Array<{ agent: string; name: string }>): Promise<{ ok: boolean; results: Array<{ name: string; ok: boolean; error?: string }> }>
31
- restart(): void
31
+ restart(): Promise<void>
32
32
  desktop: boolean
33
33
  }
34
34
 
@@ -71,6 +71,8 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
71
71
  const [importItems, setImportItems] = useState<Array<{ server: ImportedServerView; existing: boolean; checked: boolean }> | null>(null)
72
72
  const [busy, setBusy] = useState(false)
73
73
  const [pending, setPending] = useState(false)
74
+ const [restartConfirm, setRestartConfirm] = useState(false)
75
+ const [restarting, setRestarting] = useState(false)
74
76
  const [outcome, setOutcome] = useState<{ ok: boolean; text: string } | null>(null)
75
77
  const [formError, setFormError] = useState<string | null>(null)
76
78
  const [reload, setReload] = useState(0)
@@ -203,15 +205,36 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
203
205
  }
204
206
  }
205
207
 
208
+ const doRestart = (): void => {
209
+ setRestartConfirm(false)
210
+ setRestarting(true)
211
+ void injected.restart()
212
+ // 桌面模式:壳层重启完成后会重载窗口。独立模式:轮询本源,恢复即刷新。
213
+ if (injected.desktop) return
214
+ const deadline = Date.now() + 60_000
215
+ const poll = (): void => {
216
+ if (Date.now() > deadline) return
217
+ window.setTimeout(() => {
218
+ void injected.list().then(
219
+ () => { window.location.reload() },
220
+ () => { poll() },
221
+ )
222
+ }, 1500)
223
+ }
224
+ window.setTimeout(poll, 3000)
225
+ }
226
+
206
227
  const restartBanner = (
207
228
  <div className="dpc-banner" data-kind="info" role="status">
208
229
  <StateDot state="ongoing" size={10} />
209
230
  <div className="dpc-bannerBody">
210
- <span>{t('restartNeeded')}</span>
231
+ <span>{restarting ? t('restarting') : t('restartNeeded')}</span>
211
232
  <span className="dpc-bannerHint">
212
- {injected.desktop
213
- ? <>{t('restartDesktopHint')}{' '}<Button variant="outline" size="sm" onClick={injected.restart}>{t('restartNow')}</Button></>
214
- : t('restartOtherHint')}
233
+ {restarting
234
+ ? (!injected.desktop && t('restartPortHint'))
235
+ : injected.desktop
236
+ ? <>{t('restartDesktopHint')}{' '}<Button variant="outline" size="sm" onClick={() => setRestartConfirm(true)}>{t('restartNow')}</Button></>
237
+ : t('restartOtherHint')}
215
238
  </span>
216
239
  </div>
217
240
  </div>
@@ -225,6 +248,7 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
225
248
  <IconApiOutline14 aria-hidden="true" />
226
249
  <h3>{t('mcpTitle')}</h3>
227
250
  <span className="dpc-spacer" />
251
+ <Button variant="ghost" size="sm" disabled={restarting} onClick={() => setRestartConfirm(true)}>{t('restart')}</Button>
228
252
  <Button variant="ghost" size="sm" onClick={() => void openImport()}>{t('importServers')}</Button>
229
253
  <Button variant="primary" size="sm" onClick={openCreate}>{t('addServer')}</Button>
230
254
  </div>
@@ -236,7 +260,7 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
236
260
  <div className="dpc-bannerBody"><span>{outcome.text}</span></div>
237
261
  </div>
238
262
  )}
239
- {pending && restartBanner}
263
+ {(pending || restarting) && restartBanner}
240
264
 
241
265
  <div className="dpc-listHead">
242
266
  <h3>{t('mcpTab')}</h3>
@@ -352,6 +376,20 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
352
376
  <p>{t('removeWarn')}</p>
353
377
  </Modal>
354
378
 
379
+ <Modal
380
+ open={restartConfirm}
381
+ onClose={() => setRestartConfirm(false)}
382
+ title={t('restartConfirmTitle')}
383
+ footer={
384
+ <>
385
+ <Button variant="ghost" onClick={() => setRestartConfirm(false)}>{t('cancel')}</Button>
386
+ <Button variant="primary" onClick={doRestart}>{t('restartNow')}</Button>
387
+ </>
388
+ }
389
+ >
390
+ <p>{t('restartConfirmBody')}</p>
391
+ </Modal>
392
+
355
393
  <Modal
356
394
  open={importOpen}
357
395
  onClose={() => setImportOpen(false)}
@@ -80,7 +80,14 @@ export function apply(ctx: CapabilitiesClientContext): void {
80
80
  scanImport: () => fetchJson<{ servers: ImportedServerView[]; existing: string[] }>('/dsh-plugin-capabilities/import/scan'),
81
81
  applyImport: (items: Array<{ agent: string; name: string }>) =>
82
82
  post('/dsh-plugin-capabilities/import/apply', { items }) as Promise<{ ok: boolean; results: Array<{ name: string; ok: boolean; error?: string }> }>,
83
- restart: (): void => { window.dshDesktop?.restartSidecar?.() },
83
+ restart: async (): Promise<void> => {
84
+ if (window.dshDesktop !== undefined) {
85
+ window.dshDesktop.restartSidecar?.()
86
+ return
87
+ }
88
+ // 独立 dsh web:自重启路由。连接在关停途中断开属预期,不算失败。
89
+ try { await post('/dsh-plugin-capabilities/restart', {}) } catch { /* dying mid-restart is expected */ }
90
+ },
84
91
  desktop: window.dshDesktop !== undefined,
85
92
  }
86
93
 
@@ -62,6 +62,11 @@ export const zh = {
62
62
  restartDesktopHint: '重启由桌面应用负责:托盘菜单「重启服务」。',
63
63
  restartOtherHint: '重启方式:关闭当前 dsh 进程后重新运行。',
64
64
  restartNow: '重启服务',
65
+ restart: '重启',
66
+ restartConfirmTitle: '重启 dsh?',
67
+ restartConfirmBody: '重启会中断正在进行的回合,未保存的输入可能丢失;MCP 行的变更将在重启后生效。',
68
+ restarting: '正在重启,恢复后将自动刷新页面…',
69
+ restartPortHint: '若重启后页面长时间未恢复,可能是端口已变化:在终端查看新地址后打开。',
65
70
  failed: '操作失败',
66
71
  }
67
72
 
@@ -127,5 +132,10 @@ export const en = {
127
132
  restartDesktopHint: 'The desktop app owns restarts: use the tray “Restart service”.',
128
133
  restartOtherHint: 'Restart by closing this dsh process and running it again.',
129
134
  restartNow: 'Restart service',
135
+ restart: 'Restart',
136
+ restartConfirmTitle: 'Restart dsh?',
137
+ restartConfirmBody: 'Restarting interrupts any running turn and may lose unsaved input; pending MCP changes apply after the restart.',
138
+ restarting: 'Restarting — the page will reload once the host is back…',
139
+ restartPortHint: 'If the page does not recover, the port may have changed: check the terminal for the new URL.',
130
140
  failed: 'Operation failed',
131
141
  }
@@ -0,0 +1,52 @@
1
+ import type { IncomingMessage } from 'node:http'
2
+ import { resolve } from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { dshLaunch, restartOwnedByShell, trustedRestartRequest } from './restart.ts'
5
+
6
+ describe('dshLaunch', () => {
7
+ it('replays an absolute bin entry with execArgv, runtime args, and cwd beside it', () => {
8
+ const launch = dshLaunch(
9
+ ['node', '/repo/apps/cli/src/bin.ts', 'web', '--port', '0'],
10
+ ['--expose-internals', '--import', 'tsx/esm'],
11
+ )
12
+ expect(launch.file).toBe(process.execPath)
13
+ expect(launch.args).toEqual(['--expose-internals', '--import', 'tsx/esm', resolve('/repo/apps/cli/src/bin.ts'), 'web', '--port', '0'])
14
+ expect(launch.cwd).toBe(resolve('/repo/apps/cli/src'))
15
+ expect(launch.viaShell).toBe(false)
16
+ })
17
+ it('resolves a relative source entry to an absolute path', () => {
18
+ const launch = dshLaunch(['node', 'apps/cli/src/bin.js'], [])
19
+ expect(launch.args[0]).toMatch(/^(?:[A-Za-z]:)?[\\/]/)
20
+ expect(launch.cwd).toBe(launch.args[0].replace(/[\\/]bin\.js$/, ''))
21
+ })
22
+ it('falls back to the bare dsh shim for unknown entries, keeping runtime args', () => {
23
+ expect(dshLaunch(['node', '/somewhere/server.js', 'web'])).toMatchObject({ file: 'dsh', args: ['web'], viaShell: process.platform === 'win32' })
24
+ expect(dshLaunch(['node'])).toMatchObject({ file: 'dsh', args: [] })
25
+ })
26
+ })
27
+
28
+ const request = (headers: Record<string, string | undefined>, address = '127.0.0.1'): IncomingMessage =>
29
+ ({ headers, socket: { remoteAddress: address } }) as unknown as IncomingMessage
30
+
31
+ describe('trustedRestartRequest', () => {
32
+ it('accepts a direct same-origin loopback request', () => {
33
+ expect(trustedRestartRequest(request({ origin: 'http://127.0.0.1:8080', host: '127.0.0.1:8080' }))).toBe(true)
34
+ })
35
+ it('rejects non-loopback peers and proxy forwarding traces', () => {
36
+ expect(trustedRestartRequest(request({ origin: 'http://127.0.0.1:8080', host: '127.0.0.1:8080' }, '192.168.1.5'))).toBe(false)
37
+ expect(trustedRestartRequest(request({ origin: 'http://127.0.0.1:8080', host: '127.0.0.1:8080', 'x-forwarded-for': '1.2.3.4' }))).toBe(false)
38
+ })
39
+ it('rejects cross-origin or missing origin/host', () => {
40
+ expect(trustedRestartRequest(request({ origin: 'http://evil.example', host: '127.0.0.1:8080' }))).toBe(false)
41
+ expect(trustedRestartRequest(request({ host: '127.0.0.1:8080' }))).toBe(false)
42
+ expect(trustedRestartRequest(request({ origin: 'http://127.0.0.1:8080' }))).toBe(false)
43
+ })
44
+ })
45
+
46
+ describe('restartOwnedByShell', () => {
47
+ it('is true only under the desktop marker', () => {
48
+ expect(restartOwnedByShell({ DSH_DESKTOP: '1' })).toBe(true)
49
+ expect(restartOwnedByShell({ DSH_DESKTOP: '' })).toBe(false)
50
+ expect(restartOwnedByShell({})).toBe(false)
51
+ })
52
+ })
package/src/restart.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Self-restart for standalone `dsh web`: relaunch the exact invocation that
3
+ * booted this host, then stop this process — so MCP row changes compose
4
+ * without leaving the UI. The desktop shell owns restarts there
5
+ * (DSH_DESKTOP=1 refuses this path — a supervised sidecar must never
6
+ * replace itself, or the supervisor respawns a second process).
7
+ *
8
+ * The replacement is spawned directly with windowsHide: CREATE_NO_WINDOW
9
+ * gives it a hidden console its own console children inherit (no popping
10
+ * windows), unlike a DETACHED_PROCESS spawn which leaves children to create
11
+ * visible consoles. No helper process and no powershell wrapper — on at
12
+ * least one machine a node→node→powershell→node chain was silently blocked
13
+ * by host software before the inner node could even start, while direct
14
+ * node→node spawns are the most battle-tested pattern there is.
15
+ */
16
+
17
+ import { spawn } from 'node:child_process'
18
+ import { openSync } from 'node:fs'
19
+ import { tmpdir } from 'node:os'
20
+ import { dirname, resolve } from 'node:path'
21
+ import type { IncomingMessage } from 'node:http'
22
+
23
+ /** The boot invocation to replay: entry from argv, execArgv preserved. */
24
+ export function dshLaunch(argv: readonly string[] = process.argv, execArgv: readonly string[] = process.execArgv): {
25
+ file: string
26
+ args: string[]
27
+ cwd: string | undefined
28
+ viaShell: boolean
29
+ } {
30
+ const entry = argv[1]
31
+ if (entry !== undefined && /[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
32
+ // Source launches (`pnpm dsh`) pass a relative entry that the child would
33
+ // resolve against its OWN cwd — absolutize, and keep cwd near the entry
34
+ // so execArgv module hooks (tsx/esm) stay resolvable.
35
+ const abs = resolve(entry)
36
+ return { file: process.execPath, args: [...execArgv, abs, ...argv.slice(2)], cwd: dirname(abs), viaShell: false }
37
+ }
38
+ // Bare `dsh` on Windows is a .cmd shim only a shell can start.
39
+ return { file: 'dsh', args: [...argv.slice(2)], cwd: undefined, viaShell: process.platform === 'win32' }
40
+ }
41
+
42
+ /**
43
+ * Relaunch this exact dsh invocation, then stop this process. The replacement
44
+ * boots slowly (module loading) while this process dies within 500 ms, so
45
+ * port handover needs no delay even for fixed-port launches. Replacement
46
+ * output is logged under tmpdir for post-mortem.
47
+ */
48
+ export function scheduleRestart(launch: ReturnType<typeof dshLaunch>): {
49
+ pid: number
50
+ replacementPid: number | undefined
51
+ logOut: string
52
+ logErr: string
53
+ } {
54
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
55
+ const logOut = `${tmpdir()}${tmpdir().endsWith('/') ? '' : '\\'}dsh-plugin-capabilities-restart-${stamp}.out.log`
56
+ const logErr = logOut.replace('.out.log', '.err.log')
57
+ const child = spawn(launch.file, launch.args, {
58
+ cwd: launch.cwd,
59
+ stdio: ['ignore', openSync(logOut, 'a'), openSync(logErr, 'a')],
60
+ env: process.env,
61
+ shell: launch.viaShell,
62
+ windowsHide: true,
63
+ })
64
+ child.unref()
65
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), 500)
66
+ return { pid: process.pid, replacementPid: child.pid, logOut, logErr }
67
+ }
68
+
69
+ /**
70
+ * A restart request is process control: only a direct same-origin loopback
71
+ * request qualifies. Any forwarding trace means the loopback peer is a
72
+ * proxy, not the user's browser.
73
+ */
74
+ export function trustedRestartRequest(request: IncomingMessage, socketAddress?: string): boolean {
75
+ const address = socketAddress ?? (request.socket.remoteAddress ?? '')
76
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
77
+ if (request.headers.forwarded !== undefined
78
+ || request.headers['x-forwarded-for'] !== undefined
79
+ || request.headers['x-real-ip'] !== undefined) return false
80
+ const origin = request.headers.origin
81
+ const host = request.headers.host
82
+ if (origin === undefined || host === undefined) return false
83
+ try {
84
+ const parsed = new URL(origin)
85
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host
86
+ } catch {
87
+ return false
88
+ }
89
+ }
90
+
91
+ /** Restart ownership: the desktop shell supervises the sidecar and restarts it. */
92
+ export function restartOwnedByShell(env: NodeJS.ProcessEnv = process.env): boolean {
93
+ return env.DSH_DESKTOP === '1'
94
+ }
package/src/routes.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http'
4
4
  import { scanAllMcp } from './agents.ts'
5
5
  import { readJsonBody, sameOrigin, sendJson } from './http.ts'
6
+ import { dshLaunch, restartOwnedByShell, scheduleRestart, trustedRestartRequest } from './restart.ts'
6
7
  import { deleteSkill, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
7
8
  import { listMcp, removeMcp, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput } from './mcp.ts'
8
9
  import type { CapabilitiesHost } from './types.ts'
@@ -279,6 +280,29 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: { profil
279
280
  }
280
281
  },
281
282
  }),
283
+
284
+ host.webServer.register({
285
+ kind: 'exact',
286
+ path: '/dsh-plugin-capabilities/restart',
287
+ handler: (request: IncomingMessage, response: ServerResponse) => {
288
+ if (request.method !== 'POST') {
289
+ response.writeHead(405, { allow: 'POST' })
290
+ response.end()
291
+ return
292
+ }
293
+ // 进程控制:仅直接的同源回环请求;桌面模式下重启归壳层所有。
294
+ if (!trustedRestartRequest(request)) {
295
+ sendJson(response, 403, { error: 'untrusted origin' })
296
+ return
297
+ }
298
+ if (restartOwnedByShell()) {
299
+ sendJson(response, 409, { error: 'restart is owned by the desktop shell' })
300
+ return
301
+ }
302
+ const { pid, replacementPid, logOut } = scheduleRestart(dshLaunch())
303
+ sendJson(response, 200, { ok: true, pid, replacementPid, logOut })
304
+ },
305
+ }),
282
306
  ]
283
307
 
284
308
  return () => { for (const dispose of disposers) dispose() }