瀏覽代碼

fix(review): audit prepared-wrapper activation and pin generated shapes

ds-review-bot round 1 on the DSH-home integration:
- generated wrappers now inject the services their manifest needs (skills/
  tools beside loader), and loadPreparedRepository rejects a wrapper fiber
  that settles anything but ACTIVE — a composition missing a required
  service fails the repository transaction instead of committing an ACTIVE
  row over a silently PENDING child (critical finding)
- the github: source ref segment excludes '#', so 'a#b' refs fail at the
  config parser with the promised syntax instead of inside pnpm
- watchPersonalPatches re-reads the include's non-patch options per refresh
  instead of a registration-time snapshot
- the TUI smoke's cache-seeded wrapper is produced by the real
  prepareDshPlugin (cache LAYOUT stays a deliberate external pin)
- new Loader integration test drives a live repositories update through
  entry.update: generation swap, old skills removed, failed candidate
  rolled back to the previous generation
Tianyi Cui 2 月之前
父節點
當前提交
b6284c8467

+ 39 - 13
apps/cli/tests/tui-keyless-smoke.e2e.ts

@@ -7,6 +7,7 @@ import { dirname, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it } from 'vitest'
 import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
+import { PREPARED_ENTRY_FILENAME, prepareDshPlugin } from '@deepseek-ai/dsh-repository-plugin'
 import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
 import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts'
 import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts'
@@ -67,6 +68,38 @@ function seedWorkspace(
   }
 }
 
+/**
+ * Run the real `prepareDshPlugin` over an equivalent one-skill `.dsh-plugin`
+ * package and return the generated wrapper text, so the smoke's cache-seeded
+ * wrapper can never drift from the generator's template.
+ */
+async function generatePreparedWrapper(pluginName: string): Promise<string> {
+  const root = await mkdtemp(join(tmpdir(), 'dsh-smoke-wrapper-'))
+  try {
+    const plugin = join(root, '.dsh-plugin')
+    await mkdir(join(root, 'skills', 'config-only-repository'), { recursive: true })
+    await writeFile(join(root, 'skills', 'config-only-repository', 'SKILL.md'), [
+      '---',
+      'name: config-only-repository',
+      'description: Generator input; the seeded cache copy owns the visible text.',
+      '---',
+      '',
+      'Repository instructions.',
+      '',
+    ].join('\n'))
+    await mkdir(plugin, { recursive: true })
+    await writeFile(join(plugin, 'package.json'), `${JSON.stringify({
+      name: pluginName,
+      version: '0.0.0',
+      dsh: { skills: ['../skills'] },
+    }, undefined, 2)}\n`)
+    await prepareDshPlugin(plugin)
+    return await readFile(join(plugin, PREPARED_ENTRY_FILENAME), 'utf8')
+  } finally {
+    await rm(root, { recursive: true, force: true })
+  }
+}
+
 /** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */
 async function seedResumeSession(cwd: string): Promise<void> {
   const sessionCwd = realpathSync.native(cwd)
@@ -646,19 +679,12 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
     const specifier = `${source}&path:/.dsh-plugin`
     const key = createHash('sha256').update(specifier).digest('hex')
     const packageRoot = `cache/repository-plugins/${key}/node_modules/repository`
-    const manifest = { name: 'config-only-fixture', skills: ['dsh-plugin-assets/skills/0'] }
-    const wrapper = [
-      '// Generated by dsh-plugin-prepare. Do not edit.',
-      `const manifest = ${JSON.stringify(manifest)}`,
-      `export const name = ${JSON.stringify(manifest.name)}`,
-      "export const inject = ['loader']",
-      'export async function apply(ctx) {',
-      "  const runtime = ctx.loader.builtins['dsh-repository-plugin']",
-      "  if (runtime === undefined) throw new Error('missing Cordis builtin dsh-repository-plugin')",
-      '  await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest })',
-      '}',
-      '',
-    ].join('\n')
+    // Produced by the real generator (prepareDshPlugin over an equivalent
+    // .dsh-plugin package) rather than hand-written, so a wrapper-template
+    // change cannot leave this smoke exercising a stale shape. The cache
+    // LAYOUT below (sha256 key, marker, node_modules/repository) remains a
+    // deliberate external pin of the durable on-disk format.
+    const wrapper = await generatePreparedWrapper('config-only-fixture')
     const output = await smoke({
       label: 'dsh personal repository Plugin',
       tempDirPrefix: 'dsh-personal-repository-plugin-',

+ 1 - 1
docs/config-catalog.md

@@ -1009,7 +1009,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/cordis/repository-plugin/src/index.ts:41`](../packages/cordis/repository-plugin/src/index.ts)
+Source: [`packages/cordis/repository-plugin/src/index.ts:42`](../packages/cordis/repository-plugin/src/index.ts)
 
 ## `@deepseek-ai/dsh-sandbox-local`
 

+ 22 - 2
packages/cordis/repository-plugin/src/source.ts

@@ -5,15 +5,23 @@
 
 import { join, resolve } from 'node:path'
 import { pathToFileURL } from 'node:url'
-import type { Context, Fiber, Plugin } from 'cordis'
+import type { Context, Fiber, FiberState, Plugin } from 'cordis'
 import type { RepositoryCache } from '@cordisjs/plugin-loader/repository'
 import { resolveDshHome } from '@deepseek-ai/dsh-paths'
 import { PREPARED_ENTRY_FILENAME } from './format.ts'
 
+// Value mirror: Cordis's const enum has no runtime object to import. Keep
+// aligned with `packages/cordis/tool-cordis/src/fiber-state.ts`.
+const FIBER_ACTIVE = 2 as FiberState.ACTIVE
+
 /** Directory under the Harness home containing immutable repository generations. */
 export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins'
 
-const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s&]+)(?:&path:(\/[^\s&]+))?$/
+// The ref segment excludes `#` so `github:o/r#a#b` fails here — at the config
+// parser, with the syntax the error message promises — instead of inside the
+// cache's pnpm install ('misconfiguration fails loud at the earliest
+// resolvable point').
+const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s#&]+)(?:&path:(\/[^\s&]+))?$/
 
 function validPluginPath(path: string): boolean {
   const segments = path.split('/').slice(1)
@@ -67,6 +75,18 @@ export async function loadPreparedRepository(
   try {
     const plugin = await import(/* @vite-ignore */pathToFileURL(filename).href) as Plugin
     const fiber = ctx.plugin(plugin)
+    await fiber
+    // Awaiting a service-gated fiber returns while it is still PENDING (the
+    // generated wrapper injects `skills`/`tools` per its manifest). This
+    // runtime commits the repository configuration transactionally, so a
+    // composition that never provides a required service must reject the
+    // transaction here — not settle ACTIVE with a silently pending child.
+    if (fiber.state !== FIBER_ACTIVE) {
+      const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
+      /* v8 ignore next 2 -- the 'unknown' arm needs a service to appear after the state read; not deterministically stageable. */
+      const detail = missing.join(', ') || 'unknown'
+      throw new Error(`prepared wrapper did not activate (waiting for services: ${detail})`)
+    }
     return await fiber
   } catch (cause) {
     throw new Error(`failed to load prepared repository Plugin ${JSON.stringify(specifier)} from ${filename}`, { cause })

+ 69 - 0
packages/cordis/repository-plugin/tests/repository-plugin.spec.ts

@@ -296,6 +296,7 @@ describe('configured GitHub repository sources', () => {
     for (const source of [
       'github:owner/repository',
       'github:owner/repository#',
+      'github:owner/repository#a#b',
       'https://github.com/owner/repository#ref',
       'github:owner/repository#ref&path:relative/.dsh-plugin',
     ]) {
@@ -350,6 +351,52 @@ describe('configured GitHub repository sources', () => {
     await ctx.fiber.dispose()
   })
 
+  it('swaps generations on a live source-list update and rolls a failed candidate back', async () => {
+    // The headline flow: a personal-config edit reaches this plugin as a
+    // Loader entry.update, which restarts the row's fiber (old cleanup, then
+    // new apply — so the 'already registered' builtin guard must not fire).
+    const roots: Record<string, string> = {}
+    for (const generation of ['one', 'two'] as const) {
+      const root = await temporaryDirectory(`live-${generation}`)
+      await writeSkill(join(root, 'skills'), `live-skill-${generation}`)
+      const directory = await writePlugin(root, `live-fixture-${generation}`, { skills: ['../skills'] })
+      await RepositoryPlugin.prepareDshPlugin(directory)
+      roots[`github:owner/repository#${generation}&path:/.dsh-plugin`] = directory
+    }
+    vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async (specifier) => {
+      const directory = roots[specifier]
+      if (directory === undefined) throw new Error(`unprepared generation ${specifier}`)
+      return directory
+    })
+
+    // Route the row through the Loader builtin table exactly as a config tree
+    // would; the module itself is the row's plugin.
+    const ctx2 = new Context()
+    await ctx2.plugin(Loader)
+    await ctx2.plugin(SkillService)
+    ctx2.loader.builtins['repository-plugins'] = RepositoryPlugin
+    const entryId = await ctx2.loader.create({
+      name: 'cordis:repository-plugins',
+      config: { repositories: ['github:owner/repository#one'] },
+    })
+    await ctx2.loader.await()
+    await expect(ctx2.skills.get('live-skill-one')).resolves.toMatchObject({ provider: 'repository:live-fixture-one' })
+
+    const entry = ctx2.loader.resolve(entryId)
+    await entry.update({ config: { repositories: ['github:owner/repository#two'] } })
+    await ctx2.loader.await()
+    await expect(ctx2.skills.get('live-skill-one')).resolves.toBeUndefined()
+    await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' })
+
+    // A failed candidate (unprepared source) rejects the update and the
+    // transactional Loader restores the previous generation.
+    await expect(entry.update({ config: { repositories: ['github:owner/repository#missing'] } }))
+      .rejects.toThrow('unprepared generation')
+    await ctx2.loader.await()
+    await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' })
+    await ctx2.fiber.dispose()
+  })
+
   it('rejects duplicate generations and cleans the builtin after cache preparation fails', async () => {
     const ctx = new Context()
     await ctx.plugin(Loader)
@@ -368,6 +415,28 @@ describe('configured GitHub repository sources', () => {
     await ctx.fiber.dispose()
   })
 
+  it('rejects a wrapper left pending by a composition without its required services', async () => {
+    // A skills-declaring generation mounted where no skills service exists:
+    // the wrapper fiber stays PENDING, and the transaction must fail loud
+    // instead of committing an ACTIVE row over a silently inert child.
+    const root = await temporaryDirectory('pending-services')
+    await writeSkill(join(root, 'skills'), 'pending-service-skill')
+    const directory = await writePlugin(root, 'pending-service-fixture', { skills: ['../skills'] })
+    await RepositoryPlugin.prepareDshPlugin(directory)
+
+    const ctx = new Context()
+    await ctx.plugin(Loader)
+    // Deliberately NO SkillService.
+    await expect(loadPreparedRepository(ctx, { resolve: async () => directory }, 'github:owner/repository#pending&path:/.dsh-plugin'))
+      .rejects.toMatchObject({
+        message: expect.stringContaining('failed to load prepared repository Plugin') as string,
+        cause: expect.objectContaining({
+          message: expect.stringContaining('waiting for services: skills') as string,
+        }) as Error,
+      })
+    await ctx.fiber.dispose()
+  })
+
   it('labels a missing prepared wrapper with its exact source and path', async () => {
     const root = await temporaryDirectory('missing-wrapper')
     const ctx = new Context()

+ 4 - 1
packages/ui/app-boot/src/index.ts

@@ -323,8 +323,11 @@ export async function watchPersonalPatches(
   const entry = bootstrapIncludes.get(ctx)
   if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`)
   const filename = join(dir, PERSONAL_CONFIG_FILENAME)
-  const { patches: _initialPatches, ...includeConfig } = entry.options.config as Include.Config
   return hmr.registerConfig(filename, async () => {
+    // Re-read the include's non-patch options per refresh: a writer that
+    // updates the root Include's other options between refreshes (none exists
+    // today) must not have them silently reverted by a personal reload.
+    const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
     const personalPatches = loadPersonalPatches(binName, dir) ?? []
     const patches = compose(personalPatches)
     await entry.update({