返回 CodeWhale
resolve-hooks.ts
根目录 / crates / tui / extension-host / src / dsh / resolve-hooks.ts
1 /**
2 * Module resolution for plugin code: one Cordis, one schemastery, one cosmokit.
3 *
4 * DSH packages import these by bare specifier, as peers or (for schemastery,
5 * sometimes) as plain dependencies. Two copies of Cordis break `instanceof`,
6 * symbols and services, so every import of these specifiers — however the
7 * package declared it, and ignoring any copy under the package's own
8 * `node_modules` — resolves to the single instance bundled into this host.
9 *
10 * `@deepseek-ai/dsh-tools` resolves to the definition-side compat module, and
11 * `@deepseek-ai/dsh-commands/brand` (the `CommandDefinitionId` constructor DSH
12 * command plugins import) to its two identity functions. Any other `@deepseek-ai/dsh-*` package fails the import loudly: the host
13 * does not provide it, and a silent partial load would be worse.
14 *
15 * Node: `module.registerHooks`. Bun has no `registerHooks` (a named import of
16 * it fails at link time, so it is read off the namespace). Bun gets the same
17 * rules through `Bun.plugin`, with three pieces (see `installBunResolver`).
18 */
19 import * as nodeModule from 'node:module'
20 import {createHash} from 'node:crypto'
21 import {fileURLToPath,pathToFileURL} from 'node:url'
22 import {resolve,isAbsolute,dirname} from 'node:path'
23 import {readFileSync} from 'node:fs'
24 import { canonicalPath, insideKey, pathKey, unlinkedKeyMatches } from './canonical-path.ts'
25 import { RUNTIME } from '../runtime.ts'
26 import { prepareBunSource, REVIEWED_IMPORT } from './bun-closure.ts'
27
28 const SCHEME = 'codewhale-host:'
29 const REGISTRY_KEY = Symbol.for('codewhale.extension-host.modules')
30
31 /** Specifier → singleton key. */
32 const SINGLETONS: Record<string, string> = {
33 '@codewhale/dsh-composition': 'dsh-composition',
34 '@deepseek-ai/cordis-plugin-loader': 'dsh-loader',
35 '@deepseek-ai/cordis-plugin-include': 'dsh-include',
36 '@deepseek-ai/cordis-plugin-group': 'dsh-group',
37 '@deepseek-ai/cordis': 'cordis',
38 '@deepseek-ai/schemastery': 'schemastery',
39 '@deepseek-ai/cosmokit': 'cosmokit',
40 cosmokit: 'cosmokit',
41 '@deepseek-ai/dsh-tools': 'dsh-tools',
42 '@deepseek-ai/dsh-util-values': 'dsh-util-values',
43 }
44
45 /** Subpaths of a refused package that are provided anyway: exact specifier → singleton key. */
46 const SUBPATH_SINGLETONS: Record<string, string> = {
47 '@deepseek-ai/dsh-commands/brand': 'dsh-commands-brand',
48 }
49
50 export class UnsupportedPeerError extends Error {
51 constructor(readonly specifier: string) {
52 super(`requires \`${specifier}\`, which the Codewhale extension host does not provide`)
53 this.name = 'UnsupportedPeerError'
54 }
55 }
56
57 function packageName(specifier: string): string {
58 const parts = specifier.split('/')
59 return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
60 }
61
62 /** Map a bare specifier to a singleton key, `null` for "not ours", or throw for an unsupported DSH peer. */
63 export function classifySpecifier(specifier: string): string | null {
64 if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.includes(':')) return null
65 if (specifier in SUBPATH_SINGLETONS) return SUBPATH_SINGLETONS[specifier]
66 const name = packageName(specifier)
67 if (name in SINGLETONS) {
68 if (specifier !== name) throw new UnsupportedPeerError(specifier)
69 return SINGLETONS[name]
70 }
71 if (name.startsWith('@deepseek-ai/dsh-') || name.startsWith('@deepseek-ai/cordis-')) {
72 throw new UnsupportedPeerError(name)
73 }
74 return null
75 }
76
77 function virtualSource(key: string, namespace: Record<string, unknown>): string {
78 const lines = [`const ns = globalThis[Symbol.for(${JSON.stringify(REGISTRY_KEY.description)})][${JSON.stringify(key)}];`]
79 for (const name of Object.keys(namespace)) {
80 if (name === 'default') continue
81 if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue
82 lines.push(`export const ${name} = ns[${JSON.stringify(name)}];`)
83 }
84 if ('default' in namespace) lines.push('export default ns.default;')
85 return lines.join('\n')
86 }
87
88 /**
89 * A path inside a `node_modules` copy of a package the host owns or refuses:
90 * the singletons and every `@deepseek-ai/dsh-*` / `@deepseek-ai/cordis-*` peer.
91 */
92 const PEER_PATH =
93 /[\\/]node_modules[\\/]((?:@deepseek-ai[\\/](?:dsh-[^\\/]+|cordis(?:-[^\\/]+)?|schemastery|cosmokit))|cosmokit)[\\/]/
94
95 /**
96 * Bun reports a bare import it cannot find as `Cannot find package 'X'`,
97 * where Node would have run the resolve hook and thrown `UnsupportedPeerError`.
98 * This gives the same error for unsupported peers. Other errors pass through.
99 */
100 export function explainImportError(error: unknown): unknown {
101 const match = error instanceof Error ? /Cannot find package '([^']+)'/.exec(error.message) : null
102 if (!match) return error
103 try {
104 classifySpecifier(match[1])
105 } catch (unsupported) {
106 return unsupported
107 }
108 return error
109 }
110
111 /**
112 * Bun 1.4: runtime `onResolve` is not called for bare package names, only for
113 * some subpaths. So:
114 * 1. `build.module` serves each singleton by its exact name.
115 * 2. `onResolve` applies `classifySpecifier` to every bare specifier Bun does
116 * pass it, which catches subpaths such as `@deepseek-ai/cordis/lib/x`.
117 * 3. `onLoad` refuses any file under a `node_modules` copy of a peer. A second
118 * Cordis, or a dsh peer that a plugin ships itself, never loads.
119 * `explainImportError` covers peers that are not installed at all.
120 */
121 function installBunResolver(modules: Record<string, Record<string, unknown>>) {
122 const bun = (globalThis as any).Bun
123 Object.defineProperty(globalThis,REVIEWED_IMPORT,{value:async(specifier:unknown,caller:string,options?:unknown)=>{
124 if(typeof specifier==='symbol')throw new TypeError('composition import specifier cannot be a symbol')
125 const name=String(specifier)
126 const closure=closureAt(caller)
127 if(!closure)throw new Error('composition module closure is no longer admitted')
128 const target=checkedBunSpecifier(name,closure.receipt,caller,false)
129 const singleton=classifySpecifier(target)
130 if(singleton!==null)return modules[singleton]
131 return import(target,options as any)
132 },writable:false,configurable:false})
133 bun.plugin({
134 name: 'codewhale-host-modules',
135 setup(build: any) {
136 for (const [specifier, key] of Object.entries({ ...SINGLETONS, ...SUBPATH_SINGLETONS })) {
137 if (key in modules) build.module(specifier, () => ({ exports: modules[key], loader: 'object' }))
138 }
139 build.onResolve({ filter: /^[^./]/ }, (args: { path: string }) => {
140 const key = classifySpecifier(args.path)
141 if (key !== null && !(key in modules)) throw new UnsupportedPeerError(args.path)
142 return undefined
143 })
144 build.onLoad({ filter: new RegExp(`${PEER_PATH.source}|\\.(?:mjs|js|cjs|mts|cts|ts|tsx|jsx|json)$`) }, (args: { path: string }) => {
145 const match = PEER_PATH.exec(args.path)
146 if(match)throw new UnsupportedPeerError(match[1].replaceAll('\\', '/'))
147 const closure=closureAt(pathToFileURL(args.path).href)
148 if(!closure) {
149 const extension=args.path.split('.').at(-1)
150 if(extension==='json')return {contents:`export default JSON.parse(${JSON.stringify(readFileSync(args.path,'utf8'))});`,loader:'js'}
151 const loader=extension==='tsx'?'tsx'
152 :extension==='ts' || extension==='mts' || extension==='cts'?'ts'
153 :extension==='jsx' || extension==='js'?'jsx':'js'
154 return {contents:readFileSync(args.path,'utf8'),loader}
155 }
156 if(!(closure.path in closure.receipt.files) || !unlinkedInside(closure.receipt,closure.path,args.path))throw new Error('module was absent from reviewed composition closure or contains a symbolic link')
157 const bytes=readFileSync(args.path)
158 if(bytes.length>64*1024*1024 || createHash('sha256').update(bytes).digest('hex')!==closure.receipt.files[closure.path])throw new Error('composition module bytes changed after review')
159 // Bun 1.4 runtime onLoad treats its JSON loader as JS. Preserve exact
160 // JSON semantics (including __proto__ data keys) via a JS data module.
161 if(closure.path.endsWith('.json'))return {contents:`export default JSON.parse(${JSON.stringify(bytes.toString('utf8'))});`,loader:'js'}
162 let source:string
163 try {source=prepareBunSource(bytes.toString('utf8'),args.path,(specifier,require)=>checkedBunSpecifier(specifier,closure.receipt,pathToFileURL(args.path).href,require))}
164 catch(error){if(error instanceof SyntaxError)throw new Error('reviewed composition module has unsupported JavaScript syntax');throw error}
165 return {contents:source,loader:'js'}
166 })
167 },
168 })
169 }
170
171 function checkedBunSpecifier(specifier:string,closure:ReviewedClosure,caller:string,require:boolean):string {
172 const key=classifySpecifier(specifier)
173 if(key!==null) {
174 if(!(key in (globalThis as any)[REGISTRY_KEY]))throw new UnsupportedPeerError(specifier)
175 return specifier
176 }
177 if(nodeModule.isBuiltin(specifier))return specifier.startsWith('node:')?specifier:`node:${specifier}`
178 const file=(specifier.startsWith('file:') || specifier.startsWith('.') || isAbsolute(specifier))
179 ?new URL(specifier,caller):undefined
180 const path=file?(require && !specifier.startsWith('file:')?resolve(dirname(fileURLToPath(caller)),specifier):resolve(fileURLToPath(file))):undefined
181 if(!path)throw new Error('bare dependency is absent from this reviewed composition; package its reviewed relative source')
182 if(file?.search || file?.hash)throw new Error('Bun does not preserve reviewed module query or fragment identity; select [extension_host] runtime = \"node\" for this composition')
183 const inside=keyIn(closure,path)
184 if(inside===undefined || !(inside in closure.files))throw new Error('composition import escapes the reviewed file closure')
185 return require?path:file!.href
186 }
187
188 // Module graph admission for an already-reviewed composition. This is an
189 // ephemeral resolver index over the existing tree's file receipt, not an owner,
190 // session or plugin state store. Native JS remains arbitrary co-resident code.
191 //
192 // Keyed by the canonical root's `pathKey`. A module path is matched lexically
193 // against the canonical root or a raw root it was admitted under — never a raw
194 // spelling against a canonical one (see canonical-path.ts): on Windows the two
195 // differ in prefix, 8.3 names and case for the very same directory.
196 interface ReviewedClosure {files:Readonly<Record<string,string>>,refs:number,canonicalRoot:string,rawRoots:Set<string>}
197 const reviewedClosures=new Map<string,ReviewedClosure>()
198 function keyIn(closure:ReviewedClosure,path:string):string|undefined {
199 const canonical=insideKey(closure.canonicalRoot,path)
200 if(canonical!==undefined)return canonical
201 for(const root of closure.rawRoots) {
202 const raw=insideKey(root,path)
203 if(raw!==undefined)return raw
204 }
205 }
206 /** `path` is `root/key` with no link inside the root (both sides canonicalized). */
207 function unlinkedInside(closure:ReviewedClosure,key:string,path:string):boolean {
208 let canonical:string
209 try {canonical=canonicalPath(path)} catch {return false}
210 return unlinkedKeyMatches(closure.canonicalRoot,key,canonical)
211 }
212 export function admitReviewedClosure(baseUrl:string,files:Readonly<Record<string,string>>):()=>void {
213 const rawRoot=resolve(fileURLToPath(baseUrl))
214 const canonicalRoot=canonicalPath(rawRoot)
215 const id=pathKey(canonicalRoot)
216 const accepted:Record<string,string>=Object.create(null)
217 const keys=Object.keys(files)
218 if (keys.length>4096) throw new Error('reviewed composition closure exceeds its file limit')
219 for (const key of keys) {
220 if (!key || key.split('/').some(part=>!part || part==='.' || part==='..') || key.includes('\\') || key.includes(':') || isAbsolute(key) || !/^[a-f0-9]{64}$/.test(files[key])) throw new Error('invalid reviewed composition closure file')
221 accepted[key]=files[key]
222 }
223 const existing=reviewedClosures.get(id)
224 if(existing) {
225 if(Object.keys(existing.files).length!==keys.length || keys.some(key=>existing.files[key]!==accepted[key])) throw new Error('the same composition root carries different file receipts')
226 existing.refs++
227 existing.rawRoots.add(rawRoot)
228 } else reviewedClosures.set(id,{files:Object.freeze(accepted),refs:1,canonicalRoot,rawRoots:new Set([rawRoot])})
229 let disposed=false
230 return ()=>{if(disposed)return;disposed=true;const current=reviewedClosures.get(id);if(current && --current.refs===0)reviewedClosures.delete(id)}
231 }
232 /** Both runtimes consume the same exact admitted source-file receipt. */
233 export async function importReviewedModule(baseUrl:string,path:string):Promise<unknown> {
234 const rawRoot=resolve(fileURLToPath(baseUrl))
235 const id=pathKey(canonicalPath(rawRoot))
236 const receipt=reviewedClosures.get(id)
237 if(!receipt)throw new Error('composition module closure is no longer admitted')
238 // The entry keeps the caller's spelling of the root: the one the composition
239 // already read and hashed the module through.
240 const entry=resolve(rawRoot,path)
241 const admitted=closureAt(pathToFileURL(entry).href)
242 if(!admitted || admitted.root!==id || !(admitted.path in receipt.files))throw new Error('composition module is absent from reviewed closure')
243 return import(pathToFileURL(entry).href)
244 }
245 function closureAt(url:string|undefined) {
246 if(!url?.startsWith('file:'))return
247 const path=resolve(fileURLToPath(url))
248 for(const [root,receipt] of reviewedClosures) {
249 const inside=keyIn(receipt,path)
250 if(inside!==undefined)return {root,receipt,path:inside}
251 }
252 }
253
254 let installed = false
255
256 /**
257 * Install the hooks once, publishing `modules` (key → module namespace) as the
258 * singletons plugin code will see.
259 */
260 export function installResolveHooks(modules: Record<string, Record<string, unknown>>) {
261 if (installed) return
262 installed = true
263 ;(globalThis as any)[REGISTRY_KEY] = modules
264 if (RUNTIME.name === 'bun') {
265 installBunResolver(modules)
266 return
267 }
268 nodeModule.registerHooks({
269 resolve(specifier, context, nextResolve) {
270 const key = classifySpecifier(specifier)
271 if (key !== null) {
272 if (!(key in modules)) throw new UnsupportedPeerError(specifier)
273 return { url: `${SCHEME}${key}`, format: 'module', shortCircuit: true }
274 }
275 const caller=closureAt(context.parentURL)
276 if(caller && !nodeModule.isBuiltin(specifier) && !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('file:')) throw new Error('bare dependency is absent from this reviewed composition; package its reviewed relative source')
277 const result=nextResolve(specifier, context)
278 if(caller && result.url.startsWith('file:')) {
279 const target=closureAt(result.url)
280 if(!target || target.root!==caller.root || !(target.path in caller.receipt.files)) throw new Error('composition import escapes the reviewed file closure')
281 }
282 return result
283 },
284 load(url, context, nextLoad) {
285 if (url.startsWith(SCHEME)) {
286 const key = url.slice(SCHEME.length)
287 return { format: 'module', source: virtualSource(key, modules[key]), shortCircuit: true }
288 }
289 const result=nextLoad(url, context)
290 const closure=closureAt(url)
291 if(closure) {
292 if(!(closure.path in closure.receipt.files) || result.source===undefined || result.source===null) throw new Error('module was absent from reviewed composition closure')
293 // Windows runs Node with --preserve-symlinks, so a linked module keeps
294 // its in-closure URL; refuse it here as the Bun loader does.
295 if(!unlinkedInside(closure.receipt,closure.path,fileURLToPath(url))) throw new Error('module was absent from reviewed composition closure or contains a symbolic link')
296 const source=typeof result.source==='string'?Buffer.from(result.source):Buffer.from(result.source as Uint8Array)
297 if(source.length>64*1024*1024 || createHash('sha256').update(source).digest('hex')!==closure.receipt.files[closure.path]) throw new Error('composition module bytes changed after review')
298 }
299 return result
300 },
301 })
302 }
303
303 lines TYPESCRIPT