dsh-plugin-capabilities 0.1.0 → 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.
- package/README.md +16 -11
- package/lib/client.js +60 -6
- package/lib/client.js.map +2 -2
- package/lib/index.js +72 -3
- package/lib/index.js.map +3 -3
- package/package.json +1 -1
- package/src/client/McpTab.tsx +44 -6
- package/src/client/index.ts +8 -1
- package/src/client/locales.ts +10 -0
- package/src/mcp.test.ts +8 -0
- package/src/mcp.ts +7 -4
- package/src/restart.test.ts +52 -0
- package/src/restart.ts +94 -0
- package/src/routes.ts +24 -0
package/src/client/McpTab.tsx
CHANGED
|
@@ -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
|
-
{
|
|
213
|
-
?
|
|
214
|
-
:
|
|
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)}
|
package/src/client/index.ts
CHANGED
|
@@ -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 => {
|
|
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
|
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
}
|
package/src/mcp.test.ts
CHANGED
|
@@ -71,6 +71,14 @@ describe('profile patch CRUD', () => {
|
|
|
71
71
|
expect(rows.find(row => row.id === 'mcp-github')?.command).toBe('pnpm')
|
|
72
72
|
})
|
|
73
73
|
|
|
74
|
+
it('treats a create request that omits id as a create (raw JSON cast)', () => {
|
|
75
|
+
const raw = { serverName: 'probe', transport: 'stdio', command: 'node' } as unknown as McpInput
|
|
76
|
+
expect(validateMcpInput(raw)).toBeNull()
|
|
77
|
+
const id = upsertMcp(profile, raw)
|
|
78
|
+
expect(id).toBe('mcp-probe')
|
|
79
|
+
expect(listMcp(profile).find(row => row.id === 'mcp-probe')?.command).toBe('node')
|
|
80
|
+
})
|
|
81
|
+
|
|
74
82
|
it('toggles disabled and removes rows', () => {
|
|
75
83
|
expect(setMcpDisabled(profile, 'mcp-github', true)).toBe(true)
|
|
76
84
|
expect(listMcp(profile).find(row => row.id === 'mcp-github')?.disabled).toBe(true)
|
package/src/mcp.ts
CHANGED
|
@@ -94,7 +94,9 @@ export function listMcp(profileDirPath: string): McpRow[] {
|
|
|
94
94
|
/** Validate one write request; returns the rejection reason or null. */
|
|
95
95
|
export function validateMcpInput(input: McpInput): string | null {
|
|
96
96
|
if (!SERVER_NAME_RE.test(input.serverName)) return 'serverName must be 1-32 chars of A-Z a-z 0-9 _ -'
|
|
97
|
-
|
|
97
|
+
// The route casts raw JSON to McpInput; a create request may omit `id`.
|
|
98
|
+
const id = input.id ?? ''
|
|
99
|
+
if (id.includes('/') || id.includes('..')) return 'invalid id'
|
|
98
100
|
if (input.transport === 'stdio') {
|
|
99
101
|
if (input.command === undefined || input.command.trim() === '') return 'stdio transport requires a command'
|
|
100
102
|
} else if (input.url === undefined || !/^https?:\/\//.test(input.url)) {
|
|
@@ -105,14 +107,15 @@ export function validateMcpInput(input: McpInput): string | null {
|
|
|
105
107
|
|
|
106
108
|
/** Add or replace one server row. Returns the (possibly deduplicated) id. */
|
|
107
109
|
export function upsertMcp(profileDirPath: string, input: McpInput): string {
|
|
110
|
+
const inputId = input.id ?? ''
|
|
108
111
|
const doc = loadPatch(profileDirPath)
|
|
109
112
|
const seq = rowSeq(doc)
|
|
110
113
|
|
|
111
|
-
const existing =
|
|
112
|
-
? mcpRows(doc).find(item => item.get('id') ===
|
|
114
|
+
const existing = inputId !== ''
|
|
115
|
+
? mcpRows(doc).find(item => item.get('id') === inputId)
|
|
113
116
|
: undefined
|
|
114
117
|
|
|
115
|
-
let id =
|
|
118
|
+
let id = inputId !== '' ? inputId : `mcp-${input.serverName}`
|
|
116
119
|
if (existing === undefined) {
|
|
117
120
|
const taken = new Set(
|
|
118
121
|
(seq.items ?? []).map(item => String(item.get('id') ?? '')).filter(id => id !== ''),
|
|
@@ -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() }
|