dsh-vscode-mode 0.12.0 → 0.13.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 +148 -112
- package/assets/shell/dsh-open.cs +65 -149
- package/assets/shell/dsh-open.mjs +241 -0
- package/assets/shell/dsh-open.ps1 +28 -47
- package/assets/shell/dsh-open.sh +25 -66
- package/lib/client.js +30185 -29711
- package/lib/client.js.map +1 -1
- package/lib/index.js +3889 -2336
- package/lib/index.js.map +1 -1
- package/package.json +9 -8
- package/skills/dsh-vscodemode-mcp/SKILL.md +94 -132
- package/src/childEnv.ts +19 -0
- package/src/client/addToConversation.ts +4 -2
- package/src/client/ai/inlineProvider.ts +1 -8
- package/src/client/commandBridge.ts +6 -2
- package/src/client/externalOpen.ts +40 -19
- package/src/client/index.ts +17 -15
- package/src/client/keybindings.ts +18 -1
- package/src/client/monaco/loader.ts +3 -1
- package/src/client/openFlow.ts +36 -28
- package/src/client/openReceipt.ts +64 -0
- package/src/client/panelCommands.ts +101 -0
- package/src/client/pdf/pdfLoader.ts +4 -2
- package/src/client/receiptState.ts +120 -0
- package/src/client/rpc.ts +30 -4
- package/src/client/shortcutsOfficial.ts +39 -4
- package/src/client/sidebarBridge.ts +7 -0
- package/src/client/styles/mcp.css +1 -19
- package/src/client/ui/DiffBadge.ts +2 -1
- package/src/client/ui/EditorView.ts +237 -55
- package/src/client/ui/McpSettings.ts +32 -7
- package/src/client/ui/OfficialSideTab.ts +5 -2
- package/src/client/ui/QuickOpen.ts +10 -9
- package/src/client/ui/commandCatalog.ts +5 -4
- package/src/client/useOpenReceipt.ts +128 -0
- package/src/compat.ts +5 -3
- package/src/dap/manager.ts +46 -13
- package/src/devForm.ts +252 -92
- package/src/externalHandoff.ts +93 -52
- package/src/fileOpenSettings.ts +1 -24
- package/src/hostImport.ts +19 -0
- package/src/httpGuard.ts +101 -0
- package/src/index.ts +106 -97
- package/src/integrate.ts +56 -50
- package/src/lsp/transport.ts +84 -127
- package/src/mcp.ts +58 -9
- package/src/mcpIsolation.ts +211 -102
- package/src/mcpProject.ts +220 -133
- package/src/mcpRuntime.ts +343 -0
- package/src/openBridge.ts +69 -0
- package/src/openInbox.ts +207 -0
- package/src/paths.ts +8 -5
- package/src/processTree.ts +63 -0
- package/src/reveal.ts +5 -3
- package/src/routes.ts +156 -154
- package/src/rpc.ts +14 -13
- package/src/shared/appUrl.ts +19 -0
- package/src/shared/externalOpen.ts +71 -0
- package/src/shared/integration.ts +2 -0
- package/src/shared/keybindings.ts +8 -5
- package/src/shared/mcp.ts +4 -1
- package/src/shared/rpc.ts +7 -4
- package/src/shared/svn.ts +6 -8
- package/src/svnPlanTool.ts +51 -0
- package/src/unityBridge.ts +26 -3
- package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +99 -203
- package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +166 -0
- package/unity/com.dsh.editor/Editor/DshOpenWorker.cs.meta +2 -0
- package/unity/com.dsh.editor/README.md +16 -25
- package/unity/com.dsh.editor/package.json +2 -2
- package/src/client/ui/ShortcutSettings.ts +0 -294
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/** Agent-owned project MCP fibers. @author ddj 2026年09月28号 */
|
|
2
|
+
import { loadHostModule } from './hostImport.js'
|
|
3
|
+
import { entriesOf, MCP_PACKAGE, publicConfig, toolsOf } from './mcp.js'
|
|
4
|
+
import { isProjectEntryId } from './compat.js'
|
|
5
|
+
import { matchWorkspace } from './mcpIsolation.js'
|
|
6
|
+
import { listProjects, projectEntryId, reconcileProject } from './mcpProject.js'
|
|
7
|
+
import type { MpcConfig, MpcServer } from './shared/mcp.js'
|
|
8
|
+
import type { Ctx } from './store.js'
|
|
9
|
+
|
|
10
|
+
export type ProjectDef = { config: MpcConfig; enabled: boolean }
|
|
11
|
+
type Instance = { fiber?: any; key: string; path: string; error?: string; stopping?: Promise<void> }
|
|
12
|
+
type AgentRecord = { instances: Map<string, Instance>; live: boolean }
|
|
13
|
+
export type McpLoader = () => Promise<any>
|
|
14
|
+
const runtimes = new WeakMap<object, McpRuntime>()
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Load the official plugin from the running host's installation tree.
|
|
18
|
+
* @private @author ddj 2026年09月28号
|
|
19
|
+
* @returns Official MCP plugin namespace; rejects if the host package is absent.
|
|
20
|
+
*/
|
|
21
|
+
async function loadMcp(): Promise<any> {
|
|
22
|
+
return loadHostModule(MCP_PACKAGE)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve one runtime per host, shared by lifecycle and RPC contexts.
|
|
27
|
+
* @public @author ddj 2026年09月28号
|
|
28
|
+
* @param ctx Host context.
|
|
29
|
+
* @returns Lazy runtime; allocating it does not start connections.
|
|
30
|
+
*/
|
|
31
|
+
export function runtimeOf(ctx: Ctx): McpRuntime {
|
|
32
|
+
const owner = ctx.root ?? ctx
|
|
33
|
+
let runtime = runtimes.get(owner)
|
|
34
|
+
if (!runtime) {
|
|
35
|
+
runtime = new McpRuntime(ctx)
|
|
36
|
+
runtimes.set(owner, runtime)
|
|
37
|
+
}
|
|
38
|
+
return runtime
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Agent -> server -> fiber ledger. Configuration alone never opens a connection. */
|
|
42
|
+
export class McpRuntime {
|
|
43
|
+
readonly projects = new Map<string, Map<string, ProjectDef>>()
|
|
44
|
+
readonly agents = new Map<any, AgentRecord>()
|
|
45
|
+
private tail: Promise<unknown> = Promise.resolve()
|
|
46
|
+
private stopped = false
|
|
47
|
+
private readonly retired = new WeakSet<object>()
|
|
48
|
+
private module?: Promise<any>
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create an idle ledger with an injectable official-plugin resolver.
|
|
52
|
+
* @public @author ddj 2026年09月28号
|
|
53
|
+
* @param ctx Host services and lifecycle owner.
|
|
54
|
+
* @param load Official module resolver, replaceable in targeted tests.
|
|
55
|
+
*/
|
|
56
|
+
constructor(private readonly ctx: Ctx, private readonly load: McpLoader = loadMcp) {}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Serialize reconfiguration and creation; a failed operation cannot poison the queue.
|
|
60
|
+
* @private @author ddj 2026年09月28号
|
|
61
|
+
* @param action Mutation to execute while the runtime remains live.
|
|
62
|
+
* @returns Completion of this mutation, including official startup readiness.
|
|
63
|
+
*/
|
|
64
|
+
private enqueue(action: () => Promise<void>): Promise<void> {
|
|
65
|
+
const run = async () => { if (!this.stopped) await action() }
|
|
66
|
+
const next = this.tail.then(run)
|
|
67
|
+
this.tail = next.catch(() => undefined)
|
|
68
|
+
return next
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Find the longest registered workspace containing an agent's cwd.
|
|
73
|
+
* @public @author ddj 2026年09月28号
|
|
74
|
+
* @param agent Live agent.
|
|
75
|
+
* @returns Registered workspace path, or undefined outside managed workspaces.
|
|
76
|
+
*/
|
|
77
|
+
workspace(agent: any): string | undefined {
|
|
78
|
+
const paths = (this.ctx.get('workspaceRegistry')?.list?.() ?? []).map((ws: { path: string }) => ws.path)
|
|
79
|
+
return matchWorkspace(agent?.session?.header?.cwd, paths)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read configured server ownership, including disabled definitions.
|
|
84
|
+
* @public @author ddj 2026年09月28号
|
|
85
|
+
* @returns Server name to workspace mapping.
|
|
86
|
+
*/
|
|
87
|
+
owners(): Map<string, string> {
|
|
88
|
+
const names = new Map<string, string>()
|
|
89
|
+
for (const record of this.agents.values()) {
|
|
90
|
+
for (const [name, instance] of record.instances) names.set(name, instance.path)
|
|
91
|
+
}
|
|
92
|
+
for (const [path, defs] of this.projects) for (const name of defs.keys()) names.set(name, path)
|
|
93
|
+
return names
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Publish ownership changes before any provider can become visible.
|
|
98
|
+
* @private @author ddj 2026年09月28号
|
|
99
|
+
* @sideEffects Refreshes agent restrictions and scoped prompt shadows synchronously.
|
|
100
|
+
*/
|
|
101
|
+
private changed(): void {
|
|
102
|
+
this.ctx.emit?.('vscode-mode/mcp-change')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Replace one validated file snapshot and update its active agents.
|
|
107
|
+
* @public @author ddj 2026年09月28号
|
|
108
|
+
* @param path Registered workspace path.
|
|
109
|
+
* @param defs Validated source definitions.
|
|
110
|
+
* @param refresh Optional server whose unchanged fiber must also be recreated.
|
|
111
|
+
* @returns Completion after old fibers close and replacement startups settle.
|
|
112
|
+
*/
|
|
113
|
+
replace(path: string, defs: Map<string, ProjectDef>, refresh?: string): Promise<void> {
|
|
114
|
+
const update = async () => {
|
|
115
|
+
this.projects.set(path, defs)
|
|
116
|
+
this.changed()
|
|
117
|
+
for (const [agent, record] of this.agents) await this.syncAgent(agent, record, refresh)
|
|
118
|
+
this.changed()
|
|
119
|
+
}
|
|
120
|
+
return this.enqueue(update)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Attach an agent once and await its project plugin startups.
|
|
125
|
+
* @public @author ddj 2026年09月28号
|
|
126
|
+
* @param agent Created or already-active agent.
|
|
127
|
+
* @returns Completion suitable for the serial agent/created listener.
|
|
128
|
+
*/
|
|
129
|
+
attach(agent: any): Promise<void> {
|
|
130
|
+
if (this.stopped || this.retired.has(agent)) return Promise.resolve()
|
|
131
|
+
let record = this.agents.get(agent)
|
|
132
|
+
if (!record) {
|
|
133
|
+
record = { instances: new Map(), live: true }
|
|
134
|
+
this.agents.set(agent, record)
|
|
135
|
+
}
|
|
136
|
+
const target = record
|
|
137
|
+
const attach = async () => { await this.syncAgent(agent, target); this.changed() }
|
|
138
|
+
return this.enqueue(attach)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Dispose a single connection before releasing its ledger slot.
|
|
143
|
+
* @private @author ddj 2026年09月28号
|
|
144
|
+
* @param record Agent ledger.
|
|
145
|
+
* @param name Server name to stop.
|
|
146
|
+
* @returns Awaitable Cordis teardown; failures remain visible to the caller.
|
|
147
|
+
*/
|
|
148
|
+
private async stopOne(record: AgentRecord, name: string): Promise<void> {
|
|
149
|
+
const instance = record.instances.get(name)
|
|
150
|
+
if (instance) await this.stopFiber(instance)
|
|
151
|
+
if (record.instances.get(name) === instance) record.instances.delete(name)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Await exact fiber teardown, including a disposer already claimed by its owning scope.
|
|
156
|
+
* @private @author ddj 2026年09月28号
|
|
157
|
+
* @param instance Connection ledger entry.
|
|
158
|
+
* @returns Shared completion of asynchronous Cordis effects.
|
|
159
|
+
*/
|
|
160
|
+
private stopFiber(instance: Instance): Promise<void> {
|
|
161
|
+
const close = async () => {
|
|
162
|
+
const fiber = instance.fiber
|
|
163
|
+
await fiber?.dispose()
|
|
164
|
+
while (fiber?.inertia !== undefined) await fiber.inertia
|
|
165
|
+
}
|
|
166
|
+
return instance.stopping ??= close()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Cancel and drain every fiber owned by a disposed agent.
|
|
171
|
+
* @public @author ddj 2026年09月28号
|
|
172
|
+
* @param agent Disposed agent.
|
|
173
|
+
* @returns Completion of connection cleanup.
|
|
174
|
+
*/
|
|
175
|
+
async detach(agent: any): Promise<void> {
|
|
176
|
+
this.retired.add(agent)
|
|
177
|
+
const record = this.agents.get(agent)
|
|
178
|
+
if (!record) return
|
|
179
|
+
record.live = false
|
|
180
|
+
await Promise.all([...record.instances.keys()].map((name) => this.stopOne(record, name)))
|
|
181
|
+
this.agents.delete(agent)
|
|
182
|
+
this.changed()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Match the agent ledger to the current enabled definitions.
|
|
187
|
+
* @private @author ddj 2026年09月28号
|
|
188
|
+
* @param agent Scoped plugin owner.
|
|
189
|
+
* @param record Agent ledger.
|
|
190
|
+
* @param refresh Optional server to reconnect.
|
|
191
|
+
* @returns Completion of removals and scoped mounts.
|
|
192
|
+
*/
|
|
193
|
+
private async syncAgent(agent: any, record: AgentRecord, refresh?: string): Promise<void> {
|
|
194
|
+
if (!record.live || this.stopped) return
|
|
195
|
+
const path = this.workspace(agent)
|
|
196
|
+
const defs = this.projects.get(path ?? '') ?? new Map<string, ProjectDef>()
|
|
197
|
+
for (const [name, instance] of record.instances) {
|
|
198
|
+
const def = defs.get(name)
|
|
199
|
+
if (def?.enabled && instance.key === JSON.stringify(def.config) && name !== refresh) continue
|
|
200
|
+
await this.stopOne(record, name)
|
|
201
|
+
}
|
|
202
|
+
for (const [name, def] of defs) {
|
|
203
|
+
if (!record.live || this.stopped) break
|
|
204
|
+
if (!def.enabled || record.instances.has(name)) continue
|
|
205
|
+
await this.mount(agent, record, def.config, path!)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Mount the official plugin through agent.ctx; ACTIVE is not connection evidence.
|
|
211
|
+
* @private @author ddj 2026年09月28号
|
|
212
|
+
* @param agent Scoped context owner.
|
|
213
|
+
* @param record Agent ledger.
|
|
214
|
+
* @param config Validated source config.
|
|
215
|
+
* @param path Default stdio working directory.
|
|
216
|
+
* @returns Settled startup; errors are retained for the project status view.
|
|
217
|
+
*/
|
|
218
|
+
private async mount(agent: any, record: AgentRecord, config: MpcConfig, path: string): Promise<void> {
|
|
219
|
+
const instance: Instance = { key: JSON.stringify(config), path }
|
|
220
|
+
record.instances.set(config.serverName, instance)
|
|
221
|
+
try {
|
|
222
|
+
const officialMcp = await (this.module ??= this.load())
|
|
223
|
+
if (!record.live || this.stopped) return
|
|
224
|
+
const global = entriesOf(this.ctx).find((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? '')) && entry.options?.config?.serverName === config.serverName)
|
|
225
|
+
if (global) throw new Error('serverName 已被全局 MCP 使用:' + config.serverName)
|
|
226
|
+
const options = config.transport === 'stdio' ? { ...config, cwd: config.cwd || path } : { ...config }
|
|
227
|
+
instance.fiber = agent.ctx.plugin(officialMcp, options)
|
|
228
|
+
await instance.fiber
|
|
229
|
+
} catch (error) {
|
|
230
|
+
instance.error = String(error)
|
|
231
|
+
await this.stopFiber(instance)
|
|
232
|
+
instance.fiber = undefined
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Build project status from exact agent scopes without assuming ACTIVE means connected.
|
|
238
|
+
* @public @author ddj 2026年09月28号
|
|
239
|
+
* @param path Workspace to summarize.
|
|
240
|
+
* @returns Masked server views, including configured-only and disabled definitions.
|
|
241
|
+
*/
|
|
242
|
+
views(path: string): MpcServer[] {
|
|
243
|
+
const defs = this.projects.get(path) ?? new Map<string, ProjectDef>()
|
|
244
|
+
return [...defs].map(([name, def]) => this.serverView(path, name, def))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Summarize one definition and its connection instances across matching agents.
|
|
249
|
+
* @private @author ddj 2026年09月28号
|
|
250
|
+
* @param path Owning workspace.
|
|
251
|
+
* @param name Server name.
|
|
252
|
+
* @param def Persisted definition.
|
|
253
|
+
* @returns Honest status and union of tools visible to its agents.
|
|
254
|
+
*/
|
|
255
|
+
private serverView(path: string, name: string, def: ProjectDef): MpcServer {
|
|
256
|
+
const tools = new Map<string, MpcServer['tools'][number]>()
|
|
257
|
+
const instances: Instance[] = []
|
|
258
|
+
for (const [agent, record] of this.agents) {
|
|
259
|
+
if (this.workspace(agent) !== path) continue
|
|
260
|
+
const instance = record.instances.get(name)
|
|
261
|
+
if (!instance) continue
|
|
262
|
+
instances.push(instance)
|
|
263
|
+
for (const tool of toolsOf(this.ctx, name, agent)) tools.set(tool.name, tool)
|
|
264
|
+
}
|
|
265
|
+
const error = instances.find((item) => item.error)?.error
|
|
266
|
+
const fibers = instances.filter((item) => item.fiber)
|
|
267
|
+
const failed = error || fibers.some((item) => item.fiber.state === 3)
|
|
268
|
+
const status = !def.enabled ? 'disabled' : failed ? 'error' : fibers.length === 0 ? 'configured'
|
|
269
|
+
: fibers.some((item) => item.fiber.state === 1 || item.fiber.state === 0) ? 'connecting' : 'unverified'
|
|
270
|
+
return {
|
|
271
|
+
id: projectEntryId(path, name), serverName: name, enabled: def.enabled,
|
|
272
|
+
transport: def.config.transport, config: publicConfig(def.config as unknown as Record<string, unknown>),
|
|
273
|
+
status, instanceCount: fibers.length, toolCount: tools.size, tools: [...tools.values()],
|
|
274
|
+
error: error || (failed ? 'MCP 插件未正常运行' : undefined),
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Close every owned connection and prevent queued work from mounting replacements.
|
|
280
|
+
* @public @author ddj 2026年09月28号
|
|
281
|
+
* @returns Completion after connection disposal and pending startup work settle.
|
|
282
|
+
*/
|
|
283
|
+
async dispose(): Promise<void> {
|
|
284
|
+
this.stopped = true
|
|
285
|
+
await Promise.all([...this.agents.keys()].map((agent) => this.detach(agent)))
|
|
286
|
+
await this.tail
|
|
287
|
+
this.projects.clear()
|
|
288
|
+
this.module = undefined
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Remove every old global project entry before any agent-scoped mount is attempted.
|
|
294
|
+
* @public @author ddj 2026年09月28号
|
|
295
|
+
* @param ctx Host loader context.
|
|
296
|
+
* @returns Completion of loader removal and connection teardown.
|
|
297
|
+
*/
|
|
298
|
+
export async function clearLegacy(ctx: Ctx): Promise<void> {
|
|
299
|
+
const loader = ctx.get('loader')
|
|
300
|
+
for (const entry of entriesOf(ctx)) {
|
|
301
|
+
if (!isProjectEntryId(String(entry.id ?? entry.options?.id ?? ''))) continue
|
|
302
|
+
await loader.remove(entry.id)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Install awaited creation hooks, migrate global entries, and attach existing agents.
|
|
308
|
+
* @public @author ddj 2026年09月28号
|
|
309
|
+
* @param ctx Host lifecycle context; installIsolation must already be installed.
|
|
310
|
+
* @returns Readiness for index.apply to await before advertising startup completion.
|
|
311
|
+
*/
|
|
312
|
+
export async function installMcpRuntime(ctx: Ctx): Promise<void> {
|
|
313
|
+
const runtime = runtimeOf(ctx)
|
|
314
|
+
let ready: Promise<void>
|
|
315
|
+
const initialize = async () => {
|
|
316
|
+
await clearLegacy(ctx)
|
|
317
|
+
await listProjects(ctx)
|
|
318
|
+
for (const agent of ctx.get('agents')?.list?.() ?? []) await runtime.attach(agent)
|
|
319
|
+
}
|
|
320
|
+
const created = async ({ agent }: { agent: any }) => {
|
|
321
|
+
await ready
|
|
322
|
+
const path = runtime.workspace(agent)
|
|
323
|
+
if (path) {
|
|
324
|
+
try { await reconcileProject(ctx, path) }
|
|
325
|
+
catch (error) { ctx.logger?.warn?.('MCP configuration retained: ' + String(error)) }
|
|
326
|
+
}
|
|
327
|
+
await runtime.attach(agent)
|
|
328
|
+
}
|
|
329
|
+
const disposed = ({ agent }: { agent: any }) => {
|
|
330
|
+
void runtime.detach(agent).catch((error) => ctx.logger?.warn?.('MCP disposal failed: ' + String(error)))
|
|
331
|
+
}
|
|
332
|
+
const offCreate = ctx.on('agent/created', created)
|
|
333
|
+
const offDispose = ctx.on('agent/disposed', disposed)
|
|
334
|
+
const cleanup = async () => {
|
|
335
|
+
offCreate?.()
|
|
336
|
+
offDispose?.()
|
|
337
|
+
await runtime.dispose()
|
|
338
|
+
runtimes.delete(ctx.root ?? ctx)
|
|
339
|
+
}
|
|
340
|
+
ctx.effect(() => cleanup, 'vscode-mode:mcp-runtime')
|
|
341
|
+
ready = initialize()
|
|
342
|
+
await ready
|
|
343
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, rename, unlink } from 'node:fs/promises'
|
|
2
|
+
import { join, dirname } from 'node:path'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { assetsDirOf, PLUGIN_ID } from './paths.js'
|
|
5
|
+
import { findProfileDir } from './devForm.js'
|
|
6
|
+
import { checkOpenPath, ensureOpenInbox, secureOpenDir } from './openInbox.js'
|
|
7
|
+
import { INTEGRATION_BASE_DEFAULT } from './shared/integration.js'
|
|
8
|
+
import type { Ctx } from './store.js'
|
|
9
|
+
|
|
10
|
+
/** @public @author ddj 2026年09月28号
|
|
11
|
+
* Serialize only public launcher metadata, rejecting INI line injection.
|
|
12
|
+
* @param baseUrl Selected public Web URL.
|
|
13
|
+
* @param metadata Profile and local producer metadata.
|
|
14
|
+
* @returns Complete INI document.
|
|
15
|
+
*/
|
|
16
|
+
export function openIni(baseUrl: string, metadata: Record<string, string> = {}): string {
|
|
17
|
+
for (const value of [baseUrl, ...Object.values(metadata)]) if (/[\r\n\0]/.test(value)) throw new Error('无效的 launcher 配置')
|
|
18
|
+
const extra = Object.entries(metadata).map(([key, value]) => key + '=' + value + '\n').join('')
|
|
19
|
+
return '[dsh]\nbase=' + (baseUrl.trim() || INTEGRATION_BASE_DEFAULT) + '\n' + extra
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @public @author ddj 2026年09月28号
|
|
23
|
+
* Write transport metadata and helper without touching OS registration.
|
|
24
|
+
* @param ctx Active host context.
|
|
25
|
+
* @param dir Existing launcher or profile bridge directory.
|
|
26
|
+
* @param baseUrl Configured public Web URL.
|
|
27
|
+
* @param securedInbox Optional inbox already protected by this caller.
|
|
28
|
+
*/
|
|
29
|
+
export async function writeOpenConfig(ctx: Ctx, dir: string, baseUrl: string, securedInbox?: string): Promise<void> {
|
|
30
|
+
const profile = findProfileDir(ctx)
|
|
31
|
+
if (!profile) throw new Error('无法识别当前 DSH profile')
|
|
32
|
+
const inbox = securedInbox ?? await ensureOpenInbox(profile)
|
|
33
|
+
const helper = join(dir, 'dsh-open.mjs')
|
|
34
|
+
await checkOpenPath(dir)
|
|
35
|
+
await replaceOpen(helper, await readFile(join(assetsDirOf(import.meta.url), 'shell', 'dsh-open.mjs')))
|
|
36
|
+
const info = ctx.get('profileContext') as { name?: string } | undefined
|
|
37
|
+
const mode = info?.name === 'desktop' && process.versions.electron ? 'desktop' : 'web'
|
|
38
|
+
const metadata = { mode, profile, inbox, helper, node: process.execPath, nodeMode: process.versions.electron ? 'electron' : 'node' }
|
|
39
|
+
await replaceOpen(join(dir, 'dsh-open.ini'), openIni(baseUrl, metadata))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @public @author ddj 2026年09月28号
|
|
43
|
+
* Provision an independent per-profile producer for Unity without a shell-menu marker.
|
|
44
|
+
* @param ctx Current profile context.
|
|
45
|
+
* @param baseUrl Selected public Web URL.
|
|
46
|
+
* @returns Absolute profile bridge INI path.
|
|
47
|
+
*/
|
|
48
|
+
export async function ensureOpenBridge(ctx: Ctx, baseUrl: string): Promise<string> {
|
|
49
|
+
const profile = findProfileDir(ctx)
|
|
50
|
+
if (!profile) throw new Error('无法识别当前 DSH profile')
|
|
51
|
+
const inbox = await ensureOpenInbox(profile)
|
|
52
|
+
const dir = join(profile, PLUGIN_ID, 'bridge')
|
|
53
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
54
|
+
await secureOpenDir(dir)
|
|
55
|
+
await writeOpenConfig(ctx, dir, baseUrl, inbox)
|
|
56
|
+
return join(dir, 'dsh-open.ini')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @private @author ddj 2026年09月28号
|
|
60
|
+
* Replace a bridge file atomically without following a preexisting file link.
|
|
61
|
+
* @param path Destination in the validated bridge directory.
|
|
62
|
+
* @param content Trusted bundled helper or generated metadata.
|
|
63
|
+
*/
|
|
64
|
+
async function replaceOpen(path: string, content: string | Buffer): Promise<void> {
|
|
65
|
+
await checkOpenPath(dirname(path))
|
|
66
|
+
const temporary = path + '.' + randomUUID() + '.tmp'
|
|
67
|
+
await writeFile(temporary, content, { flag: 'wx', mode: 0o600 })
|
|
68
|
+
try { await rename(temporary, path) } finally { await unlink(temporary).catch(() => {}) }
|
|
69
|
+
}
|
package/src/openInbox.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { lstat, mkdir, chmod, open, rename, readdir, unlink } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join, resolve } from 'node:path'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { findProfileDir } from './devForm.js'
|
|
6
|
+
import { childEnv } from './childEnv.js'
|
|
7
|
+
import { handoffOpen, pendingState, clearPending, cancelPending } from './externalHandoff.js'
|
|
8
|
+
import { OPEN_ID, OPEN_MAX_BYTES, OPEN_TTL_MS, parseInboxOpen } from './shared/externalOpen.js'
|
|
9
|
+
import type { InboxAck, InboxOpen } from './shared/externalOpen.js'
|
|
10
|
+
import type { Ctx } from './store.js'
|
|
11
|
+
|
|
12
|
+
/** @public @author ddj 2026年09月28号
|
|
13
|
+
* Resolve the profile-owned open-only directory.
|
|
14
|
+
* @param profile Absolute active profile directory.
|
|
15
|
+
* @returns Private inbox root.
|
|
16
|
+
*/
|
|
17
|
+
export function inboxDir(profile: string): string { return join(profile, 'dsh-vscode-mode', 'open-inbox') }
|
|
18
|
+
|
|
19
|
+
/** @public @author ddj 2026年09月28号
|
|
20
|
+
* Reject symlink and reparse ancestors before filesystem transport operations.
|
|
21
|
+
* @param path Existing path to validate.
|
|
22
|
+
* @throws When any ancestor is a symlink.
|
|
23
|
+
*/
|
|
24
|
+
export async function checkOpenPath(path: string): Promise<void> {
|
|
25
|
+
let current = resolve(path)
|
|
26
|
+
for (;;) {
|
|
27
|
+
const info = await lstat(current)
|
|
28
|
+
if (info.isSymbolicLink()) throw new Error('外部打开目录不允许符号链接:' + current)
|
|
29
|
+
const parent = dirname(current)
|
|
30
|
+
if (parent === current) return
|
|
31
|
+
current = parent
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @private @author ddj 2026年09月28号
|
|
36
|
+
* Apply a fresh protected ACL granting only the current Windows identity.
|
|
37
|
+
* @param path Directory being secured.
|
|
38
|
+
* @returns Completion after PowerShell confirms the ACL.
|
|
39
|
+
*/
|
|
40
|
+
async function lockWindows(path: string): Promise<void> {
|
|
41
|
+
const literal = "'" + path.replace(/'/g, "''") + "'"
|
|
42
|
+
const script = [
|
|
43
|
+
"$ErrorActionPreference='Stop'",
|
|
44
|
+
"$ProgressPreference='SilentlyContinue'",
|
|
45
|
+
'$p=' + literal,
|
|
46
|
+
'$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User',
|
|
47
|
+
'$a=New-Object Security.AccessControl.DirectorySecurity',
|
|
48
|
+
'$a.SetAccessRuleProtection($true,$false)',
|
|
49
|
+
"$r=New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow')",
|
|
50
|
+
'$a.AddAccessRule($r)',
|
|
51
|
+
'[IO.Directory]::SetAccessControl($p,$a)',
|
|
52
|
+
].join(';')
|
|
53
|
+
const encoded = Buffer.from(script, 'utf16le').toString('base64')
|
|
54
|
+
await new Promise<void>((done, fail) => {
|
|
55
|
+
const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], { stdio: ['ignore', 'ignore', 'inherit'], windowsHide: true, env: childEnv(), timeout: 10_000 })
|
|
56
|
+
child.once('error', fail)
|
|
57
|
+
child.once('close', (code) => code === 0 ? done() : fail(new Error('无法保护外部打开目录 ACL')))
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @public @author ddj 2026年09月28号
|
|
62
|
+
* Protect an existing transport directory for the current user only.
|
|
63
|
+
* @param dir Existing directory, with no symlink or reparse ancestors.
|
|
64
|
+
*/
|
|
65
|
+
export async function secureOpenDir(dir: string): Promise<void> {
|
|
66
|
+
await checkOpenPath(dir)
|
|
67
|
+
if (process.platform === 'win32') await lockWindows(dir)
|
|
68
|
+
else await chmod(dir, 0o700)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @public @author ddj 2026年09月28号
|
|
72
|
+
* Create and protect an inbox and both transport subdirectories.
|
|
73
|
+
* @param profile Active profile directory.
|
|
74
|
+
* @returns Secured inbox root; throws on unsafe paths or ACL failures.
|
|
75
|
+
*/
|
|
76
|
+
export async function ensureOpenInbox(profile: string): Promise<string> {
|
|
77
|
+
await checkOpenPath(profile)
|
|
78
|
+
const root = inboxDir(profile)
|
|
79
|
+
for (const dir of [dirname(root), root, join(root, 'requests'), join(root, 'acks')]) {
|
|
80
|
+
try { await lstat(dir); await checkOpenPath(dir) } catch (error) {
|
|
81
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
82
|
+
await mkdir(dir, { mode: 0o700 })
|
|
83
|
+
}
|
|
84
|
+
await secureOpenDir(dir)
|
|
85
|
+
}
|
|
86
|
+
return root
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @private @author ddj 2026年09月28号
|
|
90
|
+
* Read a bounded regular single-link JSON file without accepting inode replacement.
|
|
91
|
+
* @param path Transport file.
|
|
92
|
+
* @returns Parsed untrusted JSON.
|
|
93
|
+
*/
|
|
94
|
+
async function readOpenFile(path: string): Promise<unknown> {
|
|
95
|
+
await checkOpenPath(path)
|
|
96
|
+
const before = await lstat(path)
|
|
97
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > OPEN_MAX_BYTES) throw new Error('无效的外部打开文件')
|
|
98
|
+
if (process.platform !== 'win32' && ((before.mode & 0o077) !== 0 || before.uid !== process.getuid?.())) throw new Error('外部打开文件权限不安全')
|
|
99
|
+
const handle = await open(path, 'r')
|
|
100
|
+
try {
|
|
101
|
+
const after = await handle.stat()
|
|
102
|
+
if (after.ino !== before.ino || after.dev !== before.dev || after.nlink !== 1 || after.size > OPEN_MAX_BYTES) throw new Error('外部打开文件已替换')
|
|
103
|
+
const buffer = Buffer.alloc(OPEN_MAX_BYTES + 1)
|
|
104
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
|
|
105
|
+
if (bytesRead > OPEN_MAX_BYTES) throw new Error('外部打开文件过大')
|
|
106
|
+
return JSON.parse(buffer.subarray(0, bytesRead).toString('utf8'))
|
|
107
|
+
} finally { await handle.close() }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @private @author ddj 2026年09月28号
|
|
111
|
+
* Atomically publish a bounded terminal receipt inside the private ACK directory.
|
|
112
|
+
* @param root Inbox root.
|
|
113
|
+
* @param request Original validated request.
|
|
114
|
+
* @param success Whether the editor confirmed success.
|
|
115
|
+
* @param error Optional terminal diagnostic.
|
|
116
|
+
*/
|
|
117
|
+
async function writeOpenAck(root: string, requestId: string, profile: string, success: boolean, error?: string): Promise<void> {
|
|
118
|
+
const dir = join(root, 'acks')
|
|
119
|
+
await checkOpenPath(dir)
|
|
120
|
+
const ack: InboxAck = { version: 1, requestId, profile, createdAt: Date.now(), success, error }
|
|
121
|
+
const temp = join(dir, requestId + '.' + randomUUID() + '.tmp')
|
|
122
|
+
const handle = await open(temp, 'wx', 0o600)
|
|
123
|
+
try { await handle.writeFile(JSON.stringify(ack), 'utf8') } finally { await handle.close() }
|
|
124
|
+
try { await rename(temp, join(dir, requestId + '.json')) } finally { await unlink(temp).catch(() => {}) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @private @author ddj 2026年09月28号
|
|
128
|
+
* Check for an existing terminal receipt without following links.
|
|
129
|
+
* @param root Inbox root.
|
|
130
|
+
* @param name Request file name.
|
|
131
|
+
* @returns Whether this request already has a receipt.
|
|
132
|
+
*/
|
|
133
|
+
async function ackExists(root: string, name: string): Promise<boolean> {
|
|
134
|
+
try { await lstat(join(root, 'acks', name)); return true } catch { return false }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @private @author ddj 2026年09月28号
|
|
138
|
+
* Consume validated request files once, retaining request identity until an actual ACK.
|
|
139
|
+
* @param root Inbox root.
|
|
140
|
+
* @param profile Expected profile.
|
|
141
|
+
* @param active Accepted requests awaiting completion.
|
|
142
|
+
* @param live Whether the owning lifecycle is still active.
|
|
143
|
+
*/
|
|
144
|
+
async function loadOpenFiles(root: string, profile: string, active: Map<string, InboxOpen>, live: () => boolean): Promise<void> {
|
|
145
|
+
const dir = join(root, 'requests')
|
|
146
|
+
await checkOpenPath(dir)
|
|
147
|
+
const names = (await readdir(dir)).filter((name) => name.endsWith('.json')).sort().slice(0, 128)
|
|
148
|
+
for (const name of names) {
|
|
149
|
+
if (!OPEN_ID.test(name.slice(0, -5))) continue
|
|
150
|
+
const file = join(dir, name)
|
|
151
|
+
try {
|
|
152
|
+
const raw = await readOpenFile(file)
|
|
153
|
+
const request = parseInboxOpen(raw, profile)
|
|
154
|
+
if (request && request.requestId + '.json' === name && !active.has(request.requestId)) {
|
|
155
|
+
if (!await ackExists(root, name)) {
|
|
156
|
+
if (!live()) return
|
|
157
|
+
handoffOpen(request, request.requestId, request.createdAt)
|
|
158
|
+
active.set(request.requestId, request)
|
|
159
|
+
}
|
|
160
|
+
} else if (!request) {
|
|
161
|
+
// A rejected request must not stay silent until its producer times out.
|
|
162
|
+
const declared = (raw as { profile?: unknown })?.profile
|
|
163
|
+
if (typeof declared === 'string' && !await ackExists(root, name)) {
|
|
164
|
+
if (!live()) return
|
|
165
|
+
await writeOpenAck(root, name.slice(0, -5), declared, false, '请求被拒绝:profile 与当前实例不匹配或字段不合法')
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (!live()) return
|
|
169
|
+
await unlink(file)
|
|
170
|
+
} catch { /* Unsafe, malformed, or inaccessible files are not followed or executed. */ }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @public @author ddj 2026年09月28号
|
|
175
|
+
* Poll one private profile inbox; disposal stops all intervals and queued work.
|
|
176
|
+
* @param ctx Host context exposing the active profile.
|
|
177
|
+
* @returns Asynchronous setup of the lifecycle disposer.
|
|
178
|
+
*/
|
|
179
|
+
export async function startOpenInbox(ctx: Ctx): Promise<() => void> {
|
|
180
|
+
const profile = findProfileDir(ctx)
|
|
181
|
+
if (!profile) return () => {}
|
|
182
|
+
const root = await ensureOpenInbox(profile)
|
|
183
|
+
const active = new Map<string, InboxOpen>()
|
|
184
|
+
let disposed = false
|
|
185
|
+
let running = false
|
|
186
|
+
/** @private @author ddj 2026年09月28号 Consume requests and publish only terminal results. */
|
|
187
|
+
async function tick(): Promise<void> {
|
|
188
|
+
if (disposed || running) return
|
|
189
|
+
running = true
|
|
190
|
+
try {
|
|
191
|
+
await loadOpenFiles(root, profile!, active, () => !disposed)
|
|
192
|
+
for (const [id, request] of active) {
|
|
193
|
+
if (disposed) break
|
|
194
|
+
const result = pendingState(id)
|
|
195
|
+
const expired = Date.now() >= request.createdAt + OPEN_TTL_MS
|
|
196
|
+
if (!result.completed && !expired) continue
|
|
197
|
+
if (!result.delivered || expired) cancelPending(id, result.error)
|
|
198
|
+
await writeOpenAck(root, request.requestId, request.profile, result.delivered && !expired, result.error ?? (expired ? '打开超时,请重试' : undefined))
|
|
199
|
+
active.delete(id)
|
|
200
|
+
}
|
|
201
|
+
} finally { running = false }
|
|
202
|
+
}
|
|
203
|
+
const timer = setInterval(() => { void tick().catch(() => {}) }, 400)
|
|
204
|
+
timer.unref?.()
|
|
205
|
+
void tick().catch(() => {})
|
|
206
|
+
return () => { disposed = true; clearInterval(timer); active.clear(); clearPending() }
|
|
207
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { createHash } from 'node:crypto'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
|
-
import { dirname, join } from 'node:path'
|
|
14
|
+
import { dirname, join, resolve } from 'node:path'
|
|
15
15
|
import { fileURLToPath } from 'node:url'
|
|
16
16
|
import { readdir, rm, stat } from 'node:fs/promises'
|
|
17
17
|
import { RPC_PATH } from './shared/rpc.js'
|
|
@@ -117,15 +117,18 @@ export function sessionsRoot(home = dshHome()): string {
|
|
|
117
117
|
// --endregion
|
|
118
118
|
|
|
119
119
|
/**
|
|
120
|
-
* 解析 DSH home
|
|
121
|
-
* @
|
|
120
|
+
* 解析 DSH home:空白环境值视为未设置,展开 ~ 并解析绝对路径。
|
|
121
|
+
* @public
|
|
122
|
+
* @author ddj 2026年09月28号
|
|
122
123
|
* @param env 环境映射(缺省 process.env;测试可注入)
|
|
123
124
|
* @returns 归一化的 DSH home 绝对路径
|
|
124
125
|
*/
|
|
125
126
|
export function dshHome(env: Record<string, string | undefined> = process.env): string {
|
|
126
127
|
const value = env[DSH_HOME_ENV]
|
|
127
|
-
|
|
128
|
-
return
|
|
128
|
+
const path = value && value.trim() ? value : join(homedir(), DSH_DIR_NAME)
|
|
129
|
+
if (path === '~') return resolve(homedir())
|
|
130
|
+
if (path.startsWith('~/') || path.startsWith('~\\')) return resolve(join(homedir(), path.slice(2)))
|
|
131
|
+
return resolve(path)
|
|
129
132
|
}
|
|
130
133
|
|
|
131
134
|
/** 插件缓存根(~/.dsh/dsh-vscode-mode/cache,下分 workspace/ 与 user/ 两级)。 */
|