| 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 |