dsh-plugin-capabilities 0.1.1 → 0.1.3
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 +17 -12
- package/lib/client.js +60 -6
- package/lib/client.js.map +2 -2
- package/lib/index.js +166 -34
- package/lib/index.js.map +4 -4
- package/package.json +15 -3
- 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 +42 -0
- package/src/mcp.ts +132 -38
- 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
|
@@ -44,6 +44,41 @@ describe('profile patch CRUD', () => {
|
|
|
44
44
|
expect(rows[0].env).toEqual({ GITHUB_TOKEN: 'secret' })
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
+
it('writes rows inside an anonymous insert list, never as bare entries', () => {
|
|
48
|
+
// The loader skips bare `- id:` entries whose target does not exist;
|
|
49
|
+
// only `- insert:` rows mount. This is the contract that made 0.1.2
|
|
50
|
+
// rows invisible to the agent.
|
|
51
|
+
const text = readFileSync(patch(), 'utf8')
|
|
52
|
+
expect(text).toContain('- insert:')
|
|
53
|
+
expect(text).not.toMatch(/^- id: mcp-/m)
|
|
54
|
+
expect(text).toMatch(/^ {4}- id: mcp-github$/m)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('absorbs legacy bare rows into the insert list on the next write', () => {
|
|
58
|
+
writeFileSync(patch(), [
|
|
59
|
+
'- id: dsh-market',
|
|
60
|
+
' config:',
|
|
61
|
+
' allowRestart: false',
|
|
62
|
+
'- id: mcp-open-websearch',
|
|
63
|
+
' name: "@deepseek-ai/dsh-mcp-client"',
|
|
64
|
+
' config:',
|
|
65
|
+
' serverName: open-websearch',
|
|
66
|
+
' transport: stdio',
|
|
67
|
+
' command: npx',
|
|
68
|
+
'',
|
|
69
|
+
].join('\n'))
|
|
70
|
+
|
|
71
|
+
expect(listMcp(profile)).toHaveLength(1)
|
|
72
|
+
expect(listMcp(profile)[0]).toMatchObject({ id: 'mcp-open-websearch', serverName: 'open-websearch' })
|
|
73
|
+
|
|
74
|
+
upsertMcp(profile, { id: '', serverName: 'context7', transport: 'stdio', command: 'npx' })
|
|
75
|
+
const text = readFileSync(patch(), 'utf8')
|
|
76
|
+
expect(text).not.toMatch(/^- id: mcp-open-websearch/m)
|
|
77
|
+
expect(text).toMatch(/^ {4}- id: mcp-open-websearch$/m)
|
|
78
|
+
expect(text).toContain('dsh-market')
|
|
79
|
+
expect(listMcp(profile)).toHaveLength(2)
|
|
80
|
+
})
|
|
81
|
+
|
|
47
82
|
it('preserves foreign rows and comments across edits', () => {
|
|
48
83
|
writeFileSync(patch(), [
|
|
49
84
|
'# user comment',
|
|
@@ -90,4 +125,11 @@ describe('profile patch CRUD', () => {
|
|
|
90
125
|
expect(listMcp(profile).find(row => row.id === 'mcp-github')).toBeUndefined()
|
|
91
126
|
expect(removeMcp(profile, 'mcp-github')).toBe(false)
|
|
92
127
|
})
|
|
128
|
+
|
|
129
|
+
it('drops the insert entry once its last row is removed', () => {
|
|
130
|
+
for (const row of listMcp(profile)) removeMcp(profile, row.id)
|
|
131
|
+
const text = readFileSync(patch(), 'utf8')
|
|
132
|
+
expect(text).not.toContain('- insert:')
|
|
133
|
+
expect(listMcp(profile)).toHaveLength(0)
|
|
134
|
+
})
|
|
93
135
|
})
|
package/src/mcp.ts
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
* `@deepseek-ai/dsh-mcp-client` row per server. The YAML document API keeps
|
|
4
4
|
* foreign rows and comments intact across edits. Row changes need a dsh
|
|
5
5
|
* restart to compose — callers surface that as a pending-restart notice.
|
|
6
|
+
*
|
|
7
|
+
* The loader's patch grammar distinguishes creates from overrides: a bare
|
|
8
|
+
* `- id: …` entry only overrides an existing row (target missing → skipped
|
|
9
|
+
* with a warning), while new rows must live in an anonymous `- insert:`
|
|
10
|
+
* list. Managed rows therefore always sit inside one insert entry, and any
|
|
11
|
+
* legacy bare rows (written before this contract was understood) are
|
|
12
|
+
* absorbed into it on the next write.
|
|
6
13
|
*/
|
|
7
14
|
|
|
8
15
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
@@ -38,7 +45,12 @@ export type McpInput = Omit<McpRow, 'disabled'> & { disabled?: boolean }
|
|
|
38
45
|
function loadPatch(profileDirPath: string): Document {
|
|
39
46
|
const path = join(profileDirPath, 'cordis.patch.yml')
|
|
40
47
|
const text = existsSync(path) ? readFileSync(path, 'utf8') : '[]'
|
|
41
|
-
|
|
48
|
+
const doc = parseDocument(text)
|
|
49
|
+
const contents = doc.contents as YAMLSeq | null
|
|
50
|
+
// The default-empty file parses as a flow `[]`; the patch layer is
|
|
51
|
+
// human-edited block YAML, so flip the flag before anything appends.
|
|
52
|
+
if (contents !== null && contents.flow === true && contents.items.length === 0) contents.flow = false
|
|
53
|
+
return doc
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
function savePatch(profileDirPath: string, doc: Document): void {
|
|
@@ -53,13 +65,57 @@ function toNode<T>(value: unknown): T {
|
|
|
53
65
|
|
|
54
66
|
/** The patch row sequence; an empty file's null root becomes an empty seq. */
|
|
55
67
|
function rowSeq(doc: Document): YAMLSeq<YAMLMap> {
|
|
56
|
-
if (doc.contents === null)
|
|
68
|
+
if (doc.contents === null) {
|
|
69
|
+
doc.contents = toNode<YAMLSeq<YAMLMap>>([])
|
|
70
|
+
// An empty seq defaults to flow style (`[]`); the file must stay block.
|
|
71
|
+
;(doc.contents as YAMLSeq).flow = false
|
|
72
|
+
}
|
|
57
73
|
return doc.contents as YAMLSeq<YAMLMap>
|
|
58
74
|
}
|
|
59
75
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
76
|
+
function isSeqNode(value: unknown): value is YAMLSeq<YAMLMap> {
|
|
77
|
+
return typeof value === 'object' && value !== null && Array.isArray((value as YAMLSeq).items)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A patch entry's insert list when it is the anonymous create form. */
|
|
81
|
+
function insertListOf(item: YAMLMap): YAMLSeq<YAMLMap> | undefined {
|
|
82
|
+
if (item.has('id')) return undefined
|
|
83
|
+
const node = item.get('insert')
|
|
84
|
+
return isSeqNode(node) ? node : undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Every managed row: legacy bare entries (no list) and insert-list rows. */
|
|
88
|
+
function mcpRowItems(doc: Document): { node: YAMLMap, list?: YAMLSeq<YAMLMap> }[] {
|
|
89
|
+
const found: { node: YAMLMap, list?: YAMLSeq<YAMLMap> }[] = []
|
|
90
|
+
for (const item of rowSeq(doc).items ?? []) {
|
|
91
|
+
if (item.get('name') === MCP_PLUGIN) found.push({ node: item })
|
|
92
|
+
const list = insertListOf(item)
|
|
93
|
+
for (const row of list?.items ?? []) {
|
|
94
|
+
if (row.get('name') === MCP_PLUGIN) found.push({ node: row, list })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return found
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Map one row node to its browser-facing shape. */
|
|
101
|
+
function rowToMcp(doc: Document, item: YAMLMap): McpRow {
|
|
102
|
+
// config is a YAMLMap node — materialize it before property access.
|
|
103
|
+
const configNode = item.get('config') as unknown
|
|
104
|
+
const plain = (typeof configNode === 'object' && configNode !== null && typeof (configNode as { toJS?: unknown }).toJS === 'function'
|
|
105
|
+
? (configNode as { toJS(document: Document): unknown }).toJS(doc)
|
|
106
|
+
: {}) as Record<string, unknown>
|
|
107
|
+
return {
|
|
108
|
+
id: String(item.get('id') ?? ''),
|
|
109
|
+
serverName: String(plain.serverName ?? ''),
|
|
110
|
+
transport: plain.transport === 'streamable-http' ? 'streamable-http' : 'stdio',
|
|
111
|
+
disabled: item.get('disabled') === true,
|
|
112
|
+
...(typeof plain.command === 'string' && plain.command !== '' ? { command: plain.command } : {}),
|
|
113
|
+
...(Array.isArray(plain.args) ? { args: plain.args.map(String) } : {}),
|
|
114
|
+
...(isStringMap(plain.env) ? { env: plain.env } : {}),
|
|
115
|
+
...(typeof plain.cwd === 'string' && plain.cwd !== '' ? { cwd: plain.cwd } : {}),
|
|
116
|
+
...(typeof plain.url === 'string' && plain.url !== '' ? { url: plain.url } : {}),
|
|
117
|
+
...(isStringMap(plain.headers) ? { headers: plain.headers } : {}),
|
|
118
|
+
}
|
|
63
119
|
}
|
|
64
120
|
|
|
65
121
|
function isStringMap(value: unknown): value is Record<string, string> {
|
|
@@ -67,28 +123,52 @@ function isStringMap(value: unknown): value is Record<string, string> {
|
|
|
67
123
|
return Object.values(value).every(entry => typeof entry === 'string')
|
|
68
124
|
}
|
|
69
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The insert list that owns managed rows, creating it when absent and
|
|
128
|
+
* absorbing legacy bare rows into it. Absorbed rows were inert under the
|
|
129
|
+
* loader's override-only reading of bare entries, so the move is not just
|
|
130
|
+
* cosmetic — it is what makes them compose.
|
|
131
|
+
*/
|
|
132
|
+
function managedInsert(doc: Document): YAMLSeq<YAMLMap> {
|
|
133
|
+
const seq = rowSeq(doc)
|
|
134
|
+
const bare: YAMLMap[] = []
|
|
135
|
+
let target: YAMLSeq<YAMLMap> | undefined
|
|
136
|
+
for (const item of seq.items ?? []) {
|
|
137
|
+
if (item.get('name') === MCP_PLUGIN) bare.push(item)
|
|
138
|
+
const list = insertListOf(item)
|
|
139
|
+
if (list !== undefined && list.items.some(row => row.get('name') === MCP_PLUGIN)) target ??= list
|
|
140
|
+
}
|
|
141
|
+
if (target === undefined) {
|
|
142
|
+
const entry = toNode<YAMLMap>({ insert: [] })
|
|
143
|
+
seq.add(entry)
|
|
144
|
+
target = entry.get('insert') as YAMLSeq<YAMLMap>
|
|
145
|
+
target.flow = false
|
|
146
|
+
}
|
|
147
|
+
for (const row of bare) {
|
|
148
|
+
seq.items.splice(seq.items.indexOf(row), 1)
|
|
149
|
+
target.add(row)
|
|
150
|
+
}
|
|
151
|
+
return target
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Every id in use: top-level patch entries and rows inside insert lists. */
|
|
155
|
+
function takenIds(doc: Document): Set<string> {
|
|
156
|
+
const taken = new Set<string>()
|
|
157
|
+
for (const item of rowSeq(doc).items ?? []) {
|
|
158
|
+
const id = String(item.get('id') ?? '')
|
|
159
|
+
if (id !== '') taken.add(id)
|
|
160
|
+
for (const row of insertListOf(item)?.items ?? []) {
|
|
161
|
+
const rowId = String(row.get('id') ?? '')
|
|
162
|
+
if (rowId !== '') taken.add(rowId)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return taken
|
|
166
|
+
}
|
|
167
|
+
|
|
70
168
|
/** Read every mcp-client row in the profile layer. */
|
|
71
169
|
export function listMcp(profileDirPath: string): McpRow[] {
|
|
72
170
|
const doc = loadPatch(profileDirPath)
|
|
73
|
-
return
|
|
74
|
-
// config is a YAMLMap node — materialize it before property access.
|
|
75
|
-
const configNode = item.get('config') as unknown
|
|
76
|
-
const plain = (typeof configNode === 'object' && configNode !== null && typeof (configNode as { toJS?: unknown }).toJS === 'function'
|
|
77
|
-
? (configNode as { toJS(document: Document): unknown }).toJS(doc)
|
|
78
|
-
: {}) as Record<string, unknown>
|
|
79
|
-
return {
|
|
80
|
-
id: String(item.get('id') ?? ''),
|
|
81
|
-
serverName: String(plain.serverName ?? ''),
|
|
82
|
-
transport: plain.transport === 'streamable-http' ? 'streamable-http' : 'stdio',
|
|
83
|
-
disabled: item.get('disabled') === true,
|
|
84
|
-
...(typeof plain.command === 'string' && plain.command !== '' ? { command: plain.command } : {}),
|
|
85
|
-
...(Array.isArray(plain.args) ? { args: plain.args.map(String) } : {}),
|
|
86
|
-
...(isStringMap(plain.env) ? { env: plain.env } : {}),
|
|
87
|
-
...(typeof plain.cwd === 'string' && plain.cwd !== '' ? { cwd: plain.cwd } : {}),
|
|
88
|
-
...(typeof plain.url === 'string' && plain.url !== '' ? { url: plain.url } : {}),
|
|
89
|
-
...(isStringMap(plain.headers) ? { headers: plain.headers } : {}),
|
|
90
|
-
}
|
|
91
|
-
})
|
|
171
|
+
return mcpRowItems(doc).map(({ node }) => rowToMcp(doc, node))
|
|
92
172
|
}
|
|
93
173
|
|
|
94
174
|
/** Validate one write request; returns the rejection reason or null. */
|
|
@@ -109,17 +189,15 @@ export function validateMcpInput(input: McpInput): string | null {
|
|
|
109
189
|
export function upsertMcp(profileDirPath: string, input: McpInput): string {
|
|
110
190
|
const inputId = input.id ?? ''
|
|
111
191
|
const doc = loadPatch(profileDirPath)
|
|
112
|
-
const
|
|
192
|
+
const list = managedInsert(doc)
|
|
113
193
|
|
|
114
194
|
const existing = inputId !== ''
|
|
115
|
-
?
|
|
195
|
+
? mcpRowItems(doc).find(({ node }) => String(node.get('id') ?? '') === inputId)
|
|
116
196
|
: undefined
|
|
117
197
|
|
|
118
198
|
let id = inputId !== '' ? inputId : `mcp-${input.serverName}`
|
|
119
199
|
if (existing === undefined) {
|
|
120
|
-
const taken =
|
|
121
|
-
(seq.items ?? []).map(item => String(item.get('id') ?? '')).filter(id => id !== ''),
|
|
122
|
-
)
|
|
200
|
+
const taken = takenIds(doc)
|
|
123
201
|
let suffix = 2
|
|
124
202
|
while (taken.has(id)) id = `mcp-${input.serverName}-${suffix++}`
|
|
125
203
|
}
|
|
@@ -143,8 +221,14 @@ export function upsertMcp(profileDirPath: string, input: McpInput): string {
|
|
|
143
221
|
if (input.disabled === true) row.disabled = true
|
|
144
222
|
|
|
145
223
|
const node = toNode<YAMLMap>(row)
|
|
146
|
-
if (existing === undefined)
|
|
147
|
-
|
|
224
|
+
if (existing === undefined) {
|
|
225
|
+
list.add(node)
|
|
226
|
+
} else if (existing.list !== undefined) {
|
|
227
|
+
existing.list.items.splice(existing.list.items.indexOf(existing.node), 1, node)
|
|
228
|
+
} else {
|
|
229
|
+
// Bare rows were absorbed above; reaching here means a foreign-shaped row.
|
|
230
|
+
rowSeq(doc).items.splice(rowSeq(doc).items.indexOf(existing.node), 1, node)
|
|
231
|
+
}
|
|
148
232
|
|
|
149
233
|
savePatch(profileDirPath, doc)
|
|
150
234
|
return id
|
|
@@ -153,10 +237,11 @@ export function upsertMcp(profileDirPath: string, input: McpInput): string {
|
|
|
153
237
|
/** Flip one row's disabled flag (absent = enabled). Returns false when missing. */
|
|
154
238
|
export function setMcpDisabled(profileDirPath: string, id: string, disabled: boolean): boolean {
|
|
155
239
|
const doc = loadPatch(profileDirPath)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (
|
|
159
|
-
|
|
240
|
+
managedInsert(doc)
|
|
241
|
+
const hit = mcpRowItems(doc).find(({ node }) => String(node.get('id') ?? '') === id)
|
|
242
|
+
if (hit === undefined) return false
|
|
243
|
+
if (disabled) hit.node.set('disabled', true)
|
|
244
|
+
else hit.node.delete('disabled')
|
|
160
245
|
savePatch(profileDirPath, doc)
|
|
161
246
|
return true
|
|
162
247
|
}
|
|
@@ -164,10 +249,19 @@ export function setMcpDisabled(profileDirPath: string, id: string, disabled: boo
|
|
|
164
249
|
/** Remove one server row. Returns false when missing. */
|
|
165
250
|
export function removeMcp(profileDirPath: string, id: string): boolean {
|
|
166
251
|
const doc = loadPatch(profileDirPath)
|
|
167
|
-
|
|
168
|
-
|
|
252
|
+
managedInsert(doc)
|
|
253
|
+
const hit = mcpRowItems(doc).find(({ node }) => String(node.get('id') ?? '') === id)
|
|
254
|
+
if (hit === undefined || hit.list === undefined) return false
|
|
255
|
+
hit.list.items.splice(hit.list.items.indexOf(hit.node), 1)
|
|
256
|
+
|
|
257
|
+
// An insert entry left with no rows is dead weight; drop it when the
|
|
258
|
+
// insert list is all it holds.
|
|
169
259
|
const seq = rowSeq(doc)
|
|
170
|
-
|
|
260
|
+
const owner = (seq.items ?? []).find(item => insertListOf(item) === hit.list)
|
|
261
|
+
if (owner !== undefined && hit.list.items.length === 0 && owner.items.length === 1) {
|
|
262
|
+
seq.items.splice(seq.items.indexOf(owner), 1)
|
|
263
|
+
}
|
|
264
|
+
|
|
171
265
|
savePatch(profileDirPath, doc)
|
|
172
266
|
return true
|
|
173
267
|
}
|
|
@@ -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() }
|