返回 CodeWhale
composition.ts
根目录 / crates / tui / extension-host / src / dsh / composition.ts
1 /**
2 * Immutable, reviewed DSH entry trees in the existing
3 * Cordis owner. Patch/expression/group/isolation semantics are upstream's.
4 * There is no session writer, prompt builder, skill parser or process launcher.
5 */
6 import { Context, Service, type Fiber } from '@deepseek-ai/cordis'
7 import { FiberState } from './fiber-state.ts'
8 import { createHash } from 'node:crypto'
9 import { readFile } from 'node:fs/promises'
10 import { isAbsolute, relative, resolve, sep } from 'node:path'
11 import { fileURLToPath } from 'node:url'
12 import { Loader, EntryTree, Group, type EntryOptions } from './upstream/loader/src/index.ts'
13 import { applyEntryPatches, entryListSchema, type PatchOptions } from './upstream/include/src/index.ts'
14 import { load as loadYaml } from 'js-yaml'
15 import {reviewedMcpModule} from './mcp.ts'
16 import {reviewedSkillModule} from './skill-filesystem.ts'
17 import {reviewedHookModule} from './shell-hooks.ts'
18 import {admitReviewedClosure,importReviewedModule} from './resolve-hooks.ts'
19 import { PERSONA, reviewedPersonaModule } from './persona.ts'
20 import { AGENT_PRESETS, defineReviewedAgentPresets, type PresetCatalog, type PreparedPreset } from './agent-presets.ts'
21
22 const MAX_LAYERS = 64
23 const MAX_SOURCE_BYTES = 1024 * 1024
24 const MAX_DATA_BYTES = 4 * 1024 * 1024
25 const MAX_DATA_NODES = 100_000
26 const MAX_ROWS = 1024
27 const MAX_DEPTH = 32
28 const SHA256 = /^[a-f0-9]{64}$/
29
30 export interface ReviewedLayer {
31 /** Original selected layer, for nonexecuting diagnostics. */
32 path: string
33 sha256: string
34 /** Exact UTF-8 source; include's schema retains unevaluated !!js nodes. */
35 source: string
36 }
37
38 export interface ReviewedModule {
39 /** Exact row name; relative names are resolved during Rust staging. */
40 name: string
41 /** Path relative to the immutable Codewhale bundle root. */
42 path: string
43 sha256: string
44 }
45
46 export interface ReviewedComposition {
47 version: 1
48 layers: ReviewedLayer[]
49 modules: ReviewedModule[]
50 /** Original source/asset closure. Rust additionally hashes generated files. */
51 files: Record<string, string>
52 }
53
54 export interface CompositionReview {
55 version: 1
56 /** Upstream's exact effective data. Expression nodes are still data. */
57 entries: EntryOptions[]
58 warnings: string[]
59 skipped: {row?:string;package?:string;reason:string;layer:string;patch:number}[]
60 layers: { path: string; sha256: string }[]
61 }
62
63 function digest(bytes: string | Uint8Array): string {
64 return createHash('sha256').update(bytes).digest('hex')
65 }
66
67 function plainRelative(path: string): boolean {
68 return typeof path === 'string' && path.length > 0 && path.length <= 4096 && !isAbsolute(path)
69 && !path.startsWith('/') && !/[\\:\u0000-\u001f\u007f]/u.test(path)
70 && path.split('/').every(part => part !== '' && part !== '.' && part !== '..')
71 }
72
73 /** Bound traversal before clone/stringification, including repeated YAML aliases. */
74 function boundData(root: unknown): void {
75 let nodes = 0
76 let bytes = 0
77 const ancestors = new Set<object>()
78 function visit(value: unknown, depth: number) {
79 if (++nodes > MAX_DATA_NODES || depth > MAX_DEPTH) throw new Error('composition data exceeds its resource limit')
80 if (typeof value === 'string') bytes += Buffer.byteLength(value)
81 if (bytes > MAX_DATA_BYTES) throw new Error('composition expanded text exceeds 4 MiB')
82 if (value === null || typeof value !== 'object') return
83 if (ancestors.has(value)) throw new Error('cyclic configuration data is unsupported')
84 ancestors.add(value)
85 if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
86 throw new Error('composition configuration must be plain data')
87 }
88 for (const [key, child] of Object.entries(value)) {
89 bytes += Buffer.byteLength(key)
90 visit(child, depth + 1)
91 }
92 ancestors.delete(value)
93 }
94 visit(root, 0)
95 }
96
97 function assignmentSafe(value: object): void {
98 // Patch/entry fields are assigned onto helper objects. Nested configuration
99 // remains literal data; ordinary constructor/prototype config keys are valid.
100 if (Object.hasOwn(value, '__proto__')) throw new Error('prototype-mutating entry field is unsupported')
101 }
102
103 function checkRows(rows: EntryOptions[], depth = 0, count = { value: 0 }): void {
104 if (depth > MAX_DEPTH) throw new Error('composition entry nesting exceeds 32 levels')
105 for (const row of rows) {
106 if (++count.value > MAX_ROWS) throw new Error('composition exceeds 1024 entry rows')
107 if (row === null || typeof row !== 'object' || Array.isArray(row)) throw new Error('entry row must be an object')
108 assignmentSafe(row)
109 if (row.id !== undefined && (typeof row.id !== 'string' || row.id.length > 512)) throw new Error('entry id must be a bounded string')
110 if (!(row.group===true && row.name===undefined) && (typeof row.name !== 'string' || row.name.length === 0 || row.name.length > 4096)) throw new Error('entry name must be a bounded string')
111 if ((row.group || row.name==='cordis:group' || row.name==='@deepseek-ai/cordis-plugin-group') && Array.isArray(row.config)) checkRows(row.config, depth + 1, count)
112 }
113 }
114
115 /** Raw preset files are entry lists; apply the same bounded data/row guards
116 * before the borrowed recursive discovery/inventory code sees them. */
117 export function parsePresetComposition(source: string): EntryOptions[] {
118 if (Buffer.byteLength(source) > MAX_SOURCE_BYTES) throw new Error('preset source exceeds its limit')
119 const rows = loadYaml(source, {schema:entryListSchema})
120 boundData(rows)
121 if (!Array.isArray(rows)) throw new Error('preset composition must be an entry list')
122 checkRows(rows as EntryOptions[])
123 return rows as EntryOptions[]
124 }
125
126 /** Trusted pure reviewer: never imports row modules or evaluates expressions. */
127 export function reviewComposition(spec: ReviewedComposition): CompositionReview {
128 if (!spec || spec.version !== 1 || !Array.isArray(spec.layers) || spec.layers.length === 0 || spec.layers.length > MAX_LAYERS) {
129 throw new Error('composition needs 1–64 reviewed layers')
130 }
131 let sourceBytes = 0
132 const patches: PatchOptions[] = []
133 const locations: {layer:string;patch:number}[] = []
134 const layers: CompositionReview['layers'] = []
135 for (const layer of spec.layers) {
136 if (!layer || !plainRelative(layer.path) || typeof layer.source !== 'string' || typeof layer.sha256 !== 'string' || !SHA256.test(layer.sha256)) throw new Error('invalid reviewed layer')
137 sourceBytes += Buffer.byteLength(layer.source)
138 if (sourceBytes > MAX_SOURCE_BYTES || digest(layer.source) !== layer.sha256) throw new Error('layer source changed or exceeds 1 MiB')
139 let parsed: unknown
140 try { parsed = loadYaml(layer.source, { schema: entryListSchema }) } catch { throw new Error('invalid DSH patch data') }
141 if (!Array.isArray(parsed)) throw new Error('DSH patch layer must be a list')
142 boundData(parsed)
143 for (const patch of parsed) {
144 if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('patch must be an object')
145 assignmentSafe(patch)
146 if (patch.insert !== undefined && patch.insert !== null) {
147 if (!Array.isArray(patch.insert)) throw new Error('patch insert must be a list')
148 checkRows(patch.insert)
149 }
150 }
151 patches.push(...parsed)
152 locations.push(...parsed.map((_,i)=>({layer:layer.path,patch:i+1})))
153 layers.push({ path: layer.path, sha256: layer.sha256 })
154 }
155 boundData(patches)
156 const warnings: string[] = []
157 const skipped: CompositionReview['skipped'] = []
158 let entries: EntryOptions[] = []
159 for (const [i,patch] of patches.entries()) {
160 entries = applyEntryPatches(entries,[patch],(message,...args)=>{
161 let index=0
162 const reason=message.replace(/%C/g,()=>JSON.stringify(args[index++]))
163 warnings.push(reason)
164 skipped.push({...(typeof patch.id==='string'?{row:patch.id}:{}),...(typeof patch.name==='string'?{package:patch.name}:{}),reason,...locations[i]})
165 })
166 }
167 boundData(entries)
168 checkRows(entries)
169 return { version: 1, entries, warnings, skipped, layers }
170 }
171
172 /** Known DSH rows that need an unavailable broker/service or replace core authority. */
173 const UNSUPPORTED_ROWS = new Map<string,string>()
174
175 /** These fixed Core adapters resolve without a package-provided module. */
176 export function hasReviewedRowBridge(name:string): boolean { return name==='@deepseek-ai/dsh-mcp-client' || name==='@deepseek-ai/dsh-skill-filesystem' || name==='@deepseek-ai/dsh-hooks-claude-code' || name==='@deepseek-ai/dsh-hooks-codex' || name===PERSONA }
177
178 /** Readiness uses the same known Core service boundary as final import. */
179 export function compositionRowProblem(name:string): string | undefined { return UNSUPPORTED_ROWS.get(name) }
180
181 /** Upstream Group needs its existing Loader during child creation. Declare
182 * that dependency for the host's strict Cordis service access checks. */
183 class ReviewedGroup extends Group { static inject = ['loader'] }
184
185 /** A Loader that uses upstream lifecycle/config logic without native internals. */
186 export class ReviewedLoader extends Loader {
187 constructor(ctx: Context, config: Loader.Config) {
188 super(ctx, config)
189 this.builtins.group = ReviewedGroup
190 }
191 }
192
193 // Loader installs global Cordis lifecycle observers. One host root must own
194 // exactly one loader; one loader per tree would duplicate interpolation.
195 const loaders = new WeakMap<Context, { ctx: Context; fiber: Fiber }>()
196
197 /** HostRoot calls this before any Native owner can activate. */
198 export function installCompositionLoader(root: Context): void {
199 if (loaders.has(root)) return
200 const ctx = root.isolate('loader')
201 const fiber = ctx.plugin(ReviewedLoader, {})
202 loaders.set(root, { ctx, fiber })
203 }
204
205 class ReviewedTree extends EntryTree {
206 private readonly base: string
207 private readonly modules: ReadonlyMap<string, ReviewedModule>
208 private readonly spec: ReviewedComposition
209
210 constructor(ctx: Context, base: string, spec: ReviewedComposition) {
211 super(ctx)
212 this.base = base
213 this.spec = spec
214 const modules = new Map<string, ReviewedModule>()
215 if (!Array.isArray(spec.modules) || spec.modules.length > MAX_ROWS || !spec.files || Object.keys(spec.files).length > 4096) throw new Error('invalid reviewed module closure')
216 for (const module of spec.modules) {
217 if (!module || typeof module.name !== 'string' || module.name.length === 0 || module.name.length > 4096 || modules.has(module.name) || !plainRelative(module.path) || !SHA256.test(module.sha256)
218 || spec.files[module.path] !== module.sha256) throw new Error('module map differs from reviewed closure')
219 modules.set(module.name, module)
220 }
221 this.modules = modules
222 }
223
224 /** A reviewed composition is input; teardown and self-disposal never rewrite it. */
225 write(): void {}
226
227 override import(name: string): unknown {
228 if (name === PERSONA) return reviewedPersonaModule
229 if (name === AGENT_PRESETS) return defineReviewedAgentPresets(mountReviewedComposition)
230 if (name === 'cordis:group' || name === '@deepseek-ai/cordis-plugin-group') return ReviewedGroup
231 if(name==='@deepseek-ai/dsh-mcp-client')return reviewedMcpModule()
232 if(name==='@deepseek-ai/dsh-skill-filesystem')return reviewedSkillModule()
233 if(name==='@deepseek-ai/dsh-hooks-claude-code')return reviewedHookModule('claude-code',fileURLToPath(this.base),this.spec.files)
234 if(name==='@deepseek-ai/dsh-hooks-codex')return reviewedHookModule('codex',fileURLToPath(this.base),this.spec.files)
235 if (UNSUPPORTED_ROWS.has(name)) throw new Error(`${name}: ${UNSUPPORTED_ROWS.get(name)}`)
236 const module = this.modules.get(name)
237 if (!module) throw new Error(`row requires ${JSON.stringify(name)}, absent from the reviewed module closure`)
238 return this.importReviewed(module)
239 }
240
241 private async importReviewed(module: ReviewedModule): Promise<unknown> {
242 const root = fileURLToPath(this.base)
243 const path = resolve(root, module.path)
244 const inside = relative(root, path)
245 if (inside.startsWith(`..${sep}`) || inside === '..' || isAbsolute(inside)) throw new Error('module escapes reviewed bundle')
246 if (this.spec.files[module.path] !== module.sha256 || digest(await readFile(path)) !== module.sha256) throw new Error('module changed after review')
247 // Transitive module bytes remain covered by the existing Rust Native
248 // receipt, immutable staging and per-call liveness checks. Native code is
249 // not contained from co-resident arbitrary Native code by this helper.
250 return importReviewedModule(this.base,module.path)
251 }
252 }
253
254 export interface CompositionMount {
255 readonly tree: EntryTree
256 dispose(): Promise<void>
257 }
258
259 interface ReviewedMountConfig {
260 baseUrl: string
261 spec: ReviewedComposition
262 review: CompositionReview
263 releaseClosure: () => void
264 }
265
266 const mountedTrees = new WeakMap<ReviewedMountConfig, CompositionMount>()
267
268 /** Validate configured rows too: upstream logs a failed create before it can
269 * enter tree.store, so iterating only the store would acknowledge no work. */
270 function validateInventory(group: import('./upstream/loader/src/config/group.ts').EntryGroup): void {
271 for (const row of group.data) {
272 const entry = group.tree.store[row.id]
273 if (!entry || entry.parent !== group || entry.options !== row) {
274 throw new Error(`composition row ${JSON.stringify(row.id ?? row.name)} was not created`)
275 }
276 if (entry.disabled) continue
277 if (entry.subgroup) validateInventory(entry.subgroup)
278 if (entry.subtree) validateInventory(entry.subtree.root)
279 }
280 }
281
282 /** Trusted carrier, following upstream PresetTree's explicit loader inject.
283 * The Native wrapper never gains a second loader or a global service grant. */
284 class ReviewedCarrier {
285 static inject = ['loader']
286 private readonly tree: ReviewedTree
287 private disposed = false
288
289 constructor(private readonly ctx: Context, private readonly config: ReviewedMountConfig) {
290 this.tree = new ReviewedTree(ctx, config.baseUrl, config.spec)
291 mountedTrees.set(config, { tree: this.tree, dispose: () => this.dispose() })
292 }
293
294 private async dispose(): Promise<void> {
295 if (this.disposed) return
296 this.disposed = true
297 const fibers = [...new Set([...this.tree.entries()].map(entry => entry.fiber)
298 .filter((fiber): fiber is Fiber => fiber !== undefined))].reverse()
299 const pending = this.tree.getTasks()
300 let stopError: unknown
301 try { this.tree.root.stop() } catch (error) { stopError = error }
302 const outcomes = await Promise.allSettled([
303 ...pending,
304 ...fibers.map(fiber => Promise.resolve().then(() => fiber.dispose())),
305 ])
306 try {
307 await this.tree.await()
308 if (stopError !== undefined) throw stopError
309 const failed = outcomes.find((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected')
310 if (failed) throw failed.reason
311 } finally { this.config.releaseClosure() }
312 }
313
314 async *[Service.init]() {
315 yield () => this.dispose()
316 await this.tree.root.update(this.config.review.entries)
317 await this.tree.await()
318 validateInventory(this.tree.root)
319 for (const entry of this.tree.entries()) {
320 if (entry.disabled) continue
321 if (!entry.fiber || entry.fiber.uid === null) throw new Error(`composition row ${JSON.stringify(entry.options.id ?? entry.options.name)} failed activation`)
322 await entry.fiber.await()
323 const missing = Object.keys(entry.fiber.inject).filter(name => entry.fiber!.ctx.get(name) === undefined)
324 if (missing.length || entry.fiber.state !== FiberState.ACTIVE) throw new Error(`composition row ${JSON.stringify(entry.options.id ?? entry.options.name)} is not active`)
325 }
326 const leaked = leakedServices(this.ctx, this.ctx.fiber)
327 if (leaked.length) throw new Error(`composition published shared-root services: ${leaked.join(', ')}`)
328 }
329 }
330
331 /** Called only from the reviewed generated native entry, under its owner. */
332 export async function mountReviewedComposition(ctx: Context, baseUrl: string, spec: ReviewedComposition): Promise<CompositionMount> {
333 const review = reviewComposition(spec)
334 // Anonymous legacy structural groups are explicit Group fibers; preserve the
335 // source patch identity comparison and normalize only the mounted projection.
336 function groups(rows: EntryOptions[]) { for (const row of rows) { if (row.group===true && row.name===undefined) row.name='cordis:group'; if (row.group===true && Array.isArray(row.config)) groups(row.config) } }
337 groups(review.entries)
338 const installed = loaders.get(ctx.root)
339 if (!installed) throw new Error('composition loader was not installed by this host')
340 await installed.fiber.await()
341 const loaderCtx = ctx.extend({
342 baseUrl,
343 [Context.isolate]: Object.assign(Object.create(ctx[Context.isolate]), {
344 loader: installed.ctx[Context.isolate].loader,
345 }),
346 })
347 const config: ReviewedMountConfig = { baseUrl, spec, review, releaseClosure: admitReviewedClosure(baseUrl, spec.files) }
348 const carrier = loaderCtx.plugin(ReviewedCarrier, config)
349 let disposed = false
350 const dispose = async () => {
351 if (disposed) return
352 disposed = true
353 try {
354 await mountedTrees.get(config)?.dispose()
355 await carrier.dispose()
356 } finally { config.releaseClosure() }
357 }
358 ctx.effect(() => dispose, 'reviewed DSH composition teardown')
359 try {
360 await carrier.await()
361 const mount = mountedTrees.get(config)
362 if (!mount || carrier.state !== FiberState.ACTIVE) throw new Error('reviewed composition carrier did not activate')
363 return { tree: mount.tree, dispose }
364 } catch (error) {
365 await dispose().catch(() => undefined)
366 throw error
367 }
368 }
369
370 /** Reused fiber-identity/root-realm audit from DSH agent-presets/src/mount.ts. */
371 function leakedServices(ctx: Context, mount: Fiber): string[] {
372 const rootIsolate = ctx.root[Context.isolate]
373 const leaked: string[] = []
374 for (const key of Object.getOwnPropertySymbols(ctx.reflect.store)) {
375 const impl = ctx.reflect.store[key]
376 if (impl === undefined) continue
377 let current = impl.fiber
378 while (current !== mount && current.parent.fiber !== current) current = current.parent.fiber
379 if (current === mount && rootIsolate[impl.name] === key) leaked.push(impl.name)
380 }
381 return leaked.sort((left, right) => left.localeCompare(right))
382 }
383
384 /** Installer-created Native entry mounts the raw roster row and its selected
385 * subtree in one existing owner scope. No process-wide selected preset exists. */
386 export async function mountReviewedPreset(ctx: Context, baseUrl: string, spec: ReviewedComposition, catalog: PresetCatalog, selected: PreparedPreset): Promise<CompositionMount> {
387 if (!selected) throw new Error('agent-presets: explicit selection is required when no default is configured')
388 const entries = structuredClone(reviewComposition(spec).entries)
389 let found = 0
390 function prepare(rows: EntryOptions[]) {
391 for (const row of rows) {
392 if (row.group === true || row.name === 'cordis:group' || row.name === '@deepseek-ai/cordis-plugin-group') {
393 prepare(row.config as EntryOptions[])
394 } else if (row.name === AGENT_PRESETS) {
395 found++
396 row.isolate = { ...row.isolate, agentPresets: true }
397 row.config = { codewhale_reviewed: { catalog, selected, base_url: baseUrl } }
398 }
399 }
400 }
401 prepare(entries)
402 if (found !== 1) throw new Error('exactly one raw roster row is required for a selected preset')
403 const source = JSON.stringify([{ insert: entries }])
404 const top = await mountReviewedComposition(ctx, baseUrl, { ...spec, layers: [{ path: 'selected-preset.json', sha256: digest(source), source }] })
405 try {
406 // Service injection requires ACTIVE. Build the raw roster's scope first,
407 // then mount its selected subtree, matching upstream's later standing mount.
408 const entry = [...top.tree.entries()].find(entry => entry.options.name === AGENT_PRESETS && !entry.disabled)
409 const roster = entry?.ctx.get('agentPresets' as any) as { mountSelected(): Promise<unknown> } | undefined
410 if (!roster) throw new Error('selected raw roster did not become active')
411 await roster.mountSelected()
412 return top
413 } catch (error) { await top.dispose(); throw error }
414 }
415
415 lines TYPESCRIPT