dsh-vscode-mode 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +129 -103
  2. package/assets/shell/dsh-open.cs +65 -149
  3. package/assets/shell/dsh-open.mjs +241 -0
  4. package/assets/shell/dsh-open.ps1 +28 -47
  5. package/assets/shell/dsh-open.sh +25 -66
  6. package/lib/client.js +12465 -11526
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +3889 -2336
  9. package/lib/index.js.map +1 -1
  10. package/package.json +9 -8
  11. package/skills/dsh-vscodemode-mcp/SKILL.md +94 -132
  12. package/src/childEnv.ts +19 -0
  13. package/src/client/addToConversation.ts +4 -2
  14. package/src/client/ai/inlineProvider.ts +1 -8
  15. package/src/client/commandBridge.ts +6 -2
  16. package/src/client/externalOpen.ts +40 -19
  17. package/src/client/index.ts +10 -4
  18. package/src/client/keybindings.ts +18 -1
  19. package/src/client/monaco/loader.ts +3 -1
  20. package/src/client/openFlow.ts +36 -28
  21. package/src/client/openReceipt.ts +64 -0
  22. package/src/client/panelCommands.ts +101 -0
  23. package/src/client/pdf/pdfLoader.ts +4 -2
  24. package/src/client/receiptState.ts +120 -0
  25. package/src/client/rpc.ts +30 -4
  26. package/src/client/shortcutRecorder.ts +161 -0
  27. package/src/client/shortcutsOfficial.ts +13 -4
  28. package/src/client/sidebarBridge.ts +7 -0
  29. package/src/client/ui/DiffBadge.ts +2 -1
  30. package/src/client/ui/EditorView.ts +237 -55
  31. package/src/client/ui/McpSettings.ts +6 -3
  32. package/src/client/ui/OfficialSideTab.ts +5 -2
  33. package/src/client/ui/QuickOpen.ts +10 -9
  34. package/src/client/ui/ShortcutSettings.ts +34 -54
  35. package/src/client/ui/commandCatalog.ts +5 -4
  36. package/src/client/useOpenReceipt.ts +128 -0
  37. package/src/compat.ts +5 -3
  38. package/src/dap/manager.ts +46 -13
  39. package/src/devForm.ts +252 -92
  40. package/src/externalHandoff.ts +93 -52
  41. package/src/fileOpenSettings.ts +1 -24
  42. package/src/hostImport.ts +19 -0
  43. package/src/httpGuard.ts +101 -0
  44. package/src/index.ts +106 -97
  45. package/src/integrate.ts +56 -50
  46. package/src/lsp/transport.ts +84 -127
  47. package/src/mcp.ts +58 -9
  48. package/src/mcpIsolation.ts +211 -102
  49. package/src/mcpProject.ts +220 -133
  50. package/src/mcpRuntime.ts +343 -0
  51. package/src/openBridge.ts +69 -0
  52. package/src/openInbox.ts +207 -0
  53. package/src/paths.ts +8 -5
  54. package/src/processTree.ts +63 -0
  55. package/src/reveal.ts +5 -3
  56. package/src/routes.ts +156 -154
  57. package/src/rpc.ts +14 -13
  58. package/src/shared/appUrl.ts +19 -0
  59. package/src/shared/externalOpen.ts +71 -0
  60. package/src/shared/integration.ts +2 -0
  61. package/src/shared/keybindings.ts +8 -5
  62. package/src/shared/mcp.ts +4 -1
  63. package/src/shared/rpc.ts +7 -4
  64. package/src/shared/svn.ts +6 -8
  65. package/src/svnPlanTool.ts +51 -0
  66. package/src/unityBridge.ts +26 -3
  67. package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +99 -203
  68. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +166 -0
  69. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs.meta +2 -0
  70. package/unity/com.dsh.editor/README.md +16 -25
  71. package/unity/com.dsh.editor/package.json +2 -2
@@ -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
+ }
@@ -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:DSH_HOME(去空白)→ ~/.dsh。
121
- * @author ddj 2026年09月01号
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
- if (value && value.trim()) return value
128
- return join(homedir(), DSH_DIR_NAME)
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/ 两级)。 */