fix(boot): resolve profile dependencies from real package directories

This commit is contained in:
imccyu committed 2026-09-19 13:29:13 +08:00
1 parent 4b9141a89e
commit 6d8aac066d
6 files changed
+378 -8

No files matched your search

+3
View File
@@ -17,6 +17,7 @@ import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
import { execa } from 'execa'
import * as yaml from 'js-yaml'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { testProfileResolution } from './profiles/headless/tests/profile-resolution.ts'
/** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
@@ -341,6 +342,8 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
}
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
testProfileResolution('lib')
it('requires a profile and rejects removed flags', async () => {
const bare = await runBuiltBin()
expect(bare.code).toBe(1)
+71
View File
@@ -0,0 +1,71 @@
/** Source-profile tool-dispatch evidence from the real Agent and Node module cache. */
import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-cmdline'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
/** Observations from one isolated Agent's run through the shipped headless profile. */
export interface SourceToolEvidence {
execArgv: string[]
modules: string[]
errors: string[]
events: SessionEvent<'tool/call' | 'tool/result' | 'turn/end'>[]
}
/** Services needed to drive the Agent and inspect loaded modules. */
export const inject = ['agents', 'loader']
/**
* Drive one mock-model turn after application readiness and dispose it before requesting exit.
* @param ctx - context in the source-launched headless Loader tree.
* @param config - isolated working directory for the Agent.
*/
export function apply(ctx: Context, config: { cwd: string }): void {
const ready = ctx.get('appReady')
const exit = ctx.get('appExit')
if (ready === undefined || exit === undefined) throw new Error('Source tool driver requires appReady and appExit')
const events: SourceToolEvidence['events'] = []
const errors: string[] = []
ctx.on('session/event', (_session, event) => {
if (event.type === 'tool/call' || event.type === 'tool/result' || event.type === 'turn/end') events.push(event)
}, { global: true })
ctx.on('agent/error', ({ error }) => { errors.push(error instanceof Error ? error.message : String(error)) }, { global: true })
const run = async (): Promise<void> => {
const handle = await ctx.agents.create({
sessionId: SessionId(randomUUID()),
meta: { cwd: config.cwd },
agentOptions: { provider: 'cli-mock', model: 'cli-mock' },
})
try {
handle.agent.followup(createUserMessage({
source: { kind: 'user' },
content: [{ type: 'text', text: 'Run the shell smoke command once.' }],
}))
await handle.agent.whenIdle()
const internal = ctx.loader.internal
if (internal === undefined) throw new Error('Source tool driver requires Node module-cache introspection')
const evidence: SourceToolEvidence = {
execArgv: process.execArgv,
modules: [...internal.loadCache.keys()].filter(url => /\/packages\/core\/(?:tools|agent-loop)\//.test(url)),
errors,
events,
}
process.stdout.write(`DSH_SOURCE_TOOL_RESULT ${JSON.stringify(evidence)}\n`)
} finally {
await handle.dispose()
}
exit(errors.length === 0 ? 0 : 1)
}
ctx.effect(() => ready.onReady(() => {
void run().catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`)
exit(1)
})
}), 'source tool driver')
}
@@ -0,0 +1,161 @@
/** Real CLI profile imports in source and built launches, including npm-link dependency layouts. */
import { mkdir, mkdtemp, realpath, rm, symlink, unlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { resolveExampleLaunch, type ExampleMode } from '@deepseek-ai/dsh-loader-smoke'
import { execa } from 'execa'
import { expect, it } from 'vitest'
const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
const processTimeoutMs = 75_000
const bundleName = 'profile-resolution-bundle'
const bridgeName = 'profile-resolution-bridge'
const leafName = 'profile-resolution-leaf'
const marker = 'DSH_PROFILE_RESOLUTION '
interface ResolutionEvidence {
execArgv: string[]
esm: { version: string; url: string }
cjs: { version: string; filename: string }
sameEsmLeaf: boolean
sameCjsLeaf: boolean
toolsInstance: boolean
scheduler: boolean
modules: string[]
}
async function writePackage(dir: string, manifest: Record<string, unknown>, files: Record<string, string>): Promise<void> {
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'package.json'), JSON.stringify(manifest) + '\n')
for (const [name, content] of Object.entries(files)) await writeFile(join(dir, name), content + '\n')
}
/**
* Register the same runtime-resolution assertions in the source-only and built-bin lanes.
* @param mode - source uses the CLI's ESM-only tsx hook; lib requires built workspace exports.
*/
export function testProfileResolution(mode: ExampleMode): void {
it.each(['installed', 'npm-link'] as const)(`resolves a %s profile dependency graph in ${mode} mode`, {
timeout: processTimeoutMs + 15_000, retry: 0,
}, async (layout) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-profile-resolution-'))
const links: string[] = []
try {
const home = join(root, 'home')
const profileDir = join(home, 'profiles', 'headless')
const installedBundle = join(profileDir, 'node_modules', bundleName)
const bundleDir = layout === 'npm-link' ? join(root, 'work', bundleName) : installedBundle
const installedBridge = join(bundleDir, 'node_modules', bridgeName)
const bridgeDir = layout === 'npm-link' ? join(root, 'dependencies', bridgeName) : installedBridge
const logicalLeaf = join(bundleDir, 'node_modules', leafName)
const realLeaf = join(root, 'dependencies', 'node_modules', leafName)
const exports = { import: './index.mjs', require: './index.cjs' }
await writePackage(bundleDir, {
name: bundleName, version: '1.0.0', dependencies: { [bridgeName]: '*' },
dsh: { bundle: { patch: './cordis.patch.yml' } },
}, { 'cordis.patch.yml': '[]' })
await writePackage(bridgeDir, {
name: bridgeName, version: '1.0.0', exports, dependencies: { [leafName]: '*' },
}, {
'index.mjs': `export { leaf } from '${leafName}'`,
'index.cjs': `module.exports = require('${leafName}')`,
})
for (const [dir, version] of [[logicalLeaf, '1.0.0'], [realLeaf, '2.0.0']] as const) {
await writePackage(dir, { name: leafName, version, exports }, {
'index.mjs': `export const leaf = { version: '${version}', url: import.meta.url }`,
'index.cjs': `exports.leaf = { version: '${version}', filename: __filename }`,
})
}
if (layout === 'npm-link') {
const globalBundle = join(root, 'npm-global', 'node_modules', bundleName)
for (const [target, link] of [[bundleDir, globalBundle], [globalBundle, installedBundle], [bridgeDir, installedBridge]] as const) {
await mkdir(dirname(link), { recursive: true })
await symlink(target, link, 'junction')
links.push(link)
}
}
// The probe imports from the profile, where the bundle's transitive dependencies need fallback.
await writePackage(profileDir, {
name: 'resolution-profile', private: true, dependencies: { [bundleName]: '*' },
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless', bundleName] } },
}, {
'probe.mjs': [
"import { createRequire } from 'node:module'",
"import Tools, { TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'",
`import { leaf } from '${leafName}'`,
`import { leaf as bridgeLeaf } from '${bridgeName}'`,
'const require = createRequire(import.meta.url)',
"export const inject = ['tools', 'agentLoop', 'loader']",
'export function apply(ctx) {',
" const ready = ctx.get('appReady')",
" const exit = ctx.get('appExit')",
' ctx.effect(() => ready.onReady(() => {',
` const cjs = require('${leafName}').leaf`,
' const evidence = {',
' execArgv: process.execArgv, esm: leaf, cjs,',
' sameEsmLeaf: leaf === bridgeLeaf,',
` sameCjsLeaf: cjs === require('${bridgeName}').leaf,`,
' toolsInstance: ctx.tools instanceof Tools,',
" scheduler: typeof ctx.tools[TOOL_RUNTIME_SCHEDULER]?.prepare === 'function',",
' modules: [...ctx.loader.internal.loadCache.keys()]',
' .filter(url => /\\/packages\\/core\\/(?:tools|agent-loop)\\//.test(url)),',
' }',
` process.stdout.write('${marker}' + JSON.stringify(evidence) + '\\n')`,
' exit(0)',
" }), 'profile resolution probe')",
'}',
].join('\n'),
'cordis.patch.yml': JSON.stringify([
{ id: 'headless-startup', disabled: true },
{ id: 'headless-runner', disabled: true },
{ id: 'llm-deepseek', disabled: true },
{ insert: [{ id: 'resolution-probe', name: join(profileDir, 'probe.mjs') }] },
]),
})
const launch = resolveExampleLaunch({
srcBin: join(repoRoot, 'apps/cli/src/bin.ts'),
mode, sourceImport: 'tsx/esm', tsconfigPath: join(repoRoot, 'tsconfig.json'),
configArgs: ['--profile', 'headless'],
env: {
DSH_HOME: home, DSH_AGENTS_HOME: join(root, 'agents'), DSH_TELEMETRY_DISABLED: '1',
NODE_OPTIONS: undefined, TSX_TSCONFIG_PATH: undefined,
},
})
const result = await execa(launch.command, launch.args, {
cwd: repoRoot, env: launch.env, input: '', timeout: processTimeoutMs,
killSignal: 'SIGKILL', reject: false,
})
const diagnostic = `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`
expect(result.timedOut, diagnostic).toBe(false)
expect(result.signal, diagnostic).toBeUndefined()
expect(result.exitCode, diagnostic).toBe(0)
const records = result.stdout.split('\n').filter(line => line.startsWith(marker))
expect(records, diagnostic).toHaveLength(1)
const evidence = JSON.parse(records[0]!.slice(marker.length)) as ResolutionEvidence
const selectedLeaf = layout === 'npm-link' ? realLeaf : logicalLeaf
const version = layout === 'npm-link' ? '2.0.0' : '1.0.0'
expect(evidence.esm).toEqual({ version, url: pathToFileURL(await realpath(join(selectedLeaf, 'index.mjs'))).href })
expect(evidence.cjs).toEqual({ version, filename: await realpath(join(selectedLeaf, 'index.cjs')) })
expect(evidence.sameEsmLeaf).toBe(true)
expect(evidence.sameCjsLeaf).toBe(true)
expect(evidence.toolsInstance).toBe(true)
expect(evidence.scheduler).toBe(true)
expect(evidence.execArgv).toEqual(mode === 'src' ? launch.args.slice(0, 2) : [])
for (const name of ['tools', 'agent-loop']) {
const selected = mode === 'src' ? 'src/index.ts' : 'lib/index.js'
const other = mode === 'src' ? 'lib' : 'src'
expect(evidence.modules.filter(url => url.endsWith(`/packages/core/${name}/${selected}`))).toHaveLength(1)
expect(evidence.modules.filter(url => url.includes(`/packages/core/${name}/${other}/`))).toEqual([])
}
} finally {
try {
for (const link of links.reverse()) await unlink(link)
} finally {
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
}
})
}
+91 -3
View File
@@ -1,22 +1,30 @@
import { readFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { clearedProxyEnv } from '@deepseek-ai/dsh-http-proxy'
import { execa } from 'execa'
import { describe, expect, it } from 'vitest'
import type { SourceToolEvidence } from './fixtures/source-tool-driver.ts'
import { testProfileResolution } from './profiles/headless/tests/profile-resolution.ts'
/**
* Keyless smoke for SOURCE `dsh` execution: run `apps/cli/src/bin.ts`
* with the exact production runtime vector (`node --import tsx/esm`, the
* vector the root `dsh` script invokes directly) and assert the
* required-config diagnostic. The Node compatibility matrix runs this
* WHOLE file, so a Node release changing module hooks or TypeScript handling
* required-config diagnostic, profile dependency resolution, and headless tool dispatch. The Node compatibility
* matrix runs this WHOLE file, so a Node release changing module hooks or TypeScript handling
* breaks this gate instead of every developer's `pnpm dsh`; the built-bin
* suite covers the published `lib/` entry, not this source chain.
*/
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
const dshSourceBin = 'apps/cli/src/bin.ts'
const sourceToolTimeoutMs = 90_000
describe('dsh SOURCE launcher (node --import tsx/esm)', () => {
testProfileResolution('src')
it('launches the source CLI without building', async () => {
const rootPackage = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as {
readonly scripts?: Record<string, string>
@@ -39,4 +47,84 @@ describe('dsh SOURCE launcher (node --import tsx/esm)', () => {
expect(result.stderr).toContain('--profile <name> is required')
expect(result.stdout).toBe('')
}, 30_000)
it('dispatches a source-profile shell call to a result, including sandbox-unavailable hosts, without mixing tools src/lib', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-source-tool-'))
try {
const cwd = join(root, 'workspace')
await mkdir(cwd)
const patch = join(root, 'source-tool.patch.yml')
await writeFile(patch, JSON.stringify([
{ id: 'headless-startup', disabled: true },
{ id: 'headless-runner', disabled: true },
{ id: 'llm-deepseek', disabled: true },
{ id: 'agent-default-model', config: { provider: 'cli-mock', model: 'cli-mock' } },
{ insert: [
{
id: 'cli-mock-llm',
name: join(repoRoot, 'packages/test-support/loader-smoke/tests/fixtures/cli-mock-llm.ts'),
},
{
id: 'source-tool-driver',
name: fileURLToPath(new URL('./fixtures/source-tool-driver.ts', import.meta.url)),
config: { cwd },
},
] },
]))
// Source resolution is the subject; this profile and observer need no Web dist or built driver.
const result = await execa(process.execPath, [
'--import', 'tsx/esm', dshSourceBin, '--profile', 'headless', '--patch', patch,
], {
cwd: repoRoot,
env: {
...clearedProxyEnv(),
DSH_HOME: join(root, 'home'),
DSH_AGENTS_HOME: join(root, 'agents'),
DSH_TELEMETRY_DISABLED: '1',
DSH_TOOLS_MODE: 'native',
DSH_CLI_MOCK_FAILURE: '0',
},
input: '',
timeout: sourceToolTimeoutMs - 15_000,
killSignal: 'SIGKILL',
reject: false,
})
const diagnostic = `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`
expect(result.timedOut, diagnostic).toBe(false)
expect(result.signal, diagnostic).toBeUndefined()
expect(result.exitCode, diagnostic).toBe(0)
const records = result.stdout.split('\n').filter(line => line.startsWith('DSH_SOURCE_TOOL_RESULT '))
expect(records, diagnostic).toHaveLength(1)
const evidence = JSON.parse(records[0]!.slice('DSH_SOURCE_TOOL_RESULT '.length)) as SourceToolEvidence
expect(evidence.execArgv).toEqual(['--import', 'tsx/esm'])
expect(evidence.errors).toEqual([])
for (const pkg of ['tools', 'agent-loop']) {
expect(evidence.modules.filter(url => url.endsWith(`/packages/core/${pkg}/src/index.ts`))).toHaveLength(1)
expect(evidence.modules.filter(url => url.includes(`/packages/core/${pkg}/lib/`))).toEqual([])
}
const calls = evidence.events.filter(event => event.type === 'tool/call')
expect(calls).toHaveLength(1)
expect(calls[0]!.data).toMatchObject({
callId: 'cli-smoke-call', name: process.platform === 'win32' ? 'pwsh' : 'bash',
})
const results = evidence.events.filter(event => event.type === 'tool/result')
expect(results).toHaveLength(1)
const toolResult = results[0]!.data
expect(toolResult.message.content).toHaveLength(1)
const block = toolResult.message.content[0]
expect(block).toMatchObject({ type: 'tool-result', toolCallId: 'cli-smoke-call' })
if (toolResult.error !== undefined) {
expect(toolResult.error).toMatchObject({ name: 'SandboxUnavailableError', code: 'SANDBOX_UNAVAILABLE' })
expect(block.isError).toBe(true)
} else {
expect(block.isError).not.toBe(true)
expect(block.content.filter(part => part.type === 'text').map(part => part.text).join(''))
.toContain('CLI_TOOL_ROUND_TRIP')
}
expect(evidence.events.filter(event => event.type === 'turn/end').map(event => event.data.reason))
.toEqual([{ kind: 'completed' }])
} finally {
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
}, sourceToolTimeoutMs)
})
+7 -5
View File
@@ -213,19 +213,21 @@ function resolveModuleFallbackEntries(
declarers: ReadonlyMap<string, string>
versions: ReadonlyMap<string, string | undefined>
} {
const appManifest = readModuleFallbackManifest(installAnchor)
// tsx skips workspace path mappings for importers under node_modules.
const canonicalAnchor = join(realModuleDirectory(dirname(installAnchor)), basename(installAnchor))
const appManifest = readModuleFallbackManifest(canonicalAnchor)
const links = new Map<string, string>()
const declarers = new Map<string, string>()
const versions = new Map<string, string | undefined>()
/* v8 ignore next -- a real app manifest always declares its name */
if (appManifest.name !== undefined) {
links.set(appManifest.name, dirname(installAnchor))
declarers.set(appManifest.name, installAnchor)
declarers.set(appManifest.name, canonicalAnchor)
versions.set(appManifest.name, appManifest.version)
}
// BFS over the resolvable dependency graph; the visited set is the link
// map itself (first resolution wins, matching Node's own nearest-wins).
const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: installAnchor, manifest: appManifest }]
const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: canonicalAnchor, manifest: appManifest }]
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
// Peer dependencies participate: Service Definition packages (dsh-subprocess,
// dsh-compaction, ...) are peers of their implementations, never plain
@@ -237,7 +239,7 @@ function resolveModuleFallbackEntries(
// A declared-but-uninstalled dependency cannot be a loader-visible
// plugin; skip it rather than fail the whole boot.
if (dir === undefined) continue
const manifestPath = join(dir, 'package.json')
const manifestPath = join(realModuleDirectory(dir), 'package.json')
let manifest: ProfileManifest
try {
manifest = skippedBundles.has(dep) ? readProfileManifest('dsh', dir) : readModuleFallbackManifest(manifestPath)
@@ -359,7 +361,7 @@ function dependencyClosure(
visited.add(dep)
links.set(dep, dir)
declarers?.set(dep, next.anchor)
const manifestPath = join(dir, 'package.json')
const manifestPath = join(realModuleDirectory(dir), 'package.json')
const dependencyManifest = readModuleFallbackManifest(manifestPath)
versions?.set(dep, dependencyManifest.version)
queue.push({ anchor: manifestPath, manifest: dependencyManifest })
@@ -206,6 +206,51 @@ describe('profile resolution generation', { concurrent: false }, () => {
expect(existsSync(join(f.root, 'profiles', 'node_modules'))).toBe(false)
})
it.each([
['installation', 'symlink'],
['installation', 'directory'],
['bundle', 'symlink'],
['bundle', 'directory'],
] as const)('canonicalizes transitive %s import anchors (%s packages)', async (origin, layout) => {
const f = fixture('bridge')
const linked = layout === 'symlink'
const workspace = join(f.root, 'workspace')
const bridge = linked ? join(workspace, 'bridge') : f.installed
const middle = linked ? join(workspace, 'middle') : join(bridge, 'node_modules', 'middle')
pkg(bridge, 'bridge', 1, { middle: '*' })
pkg(middle, 'middle', 2, { leaf: '*' })
const logicalLeaf = join(bridge, 'node_modules', 'leaf')
const workspaceLeaf = join(workspace, 'node_modules', 'leaf')
pkg(logicalLeaf, 'leaf', 1)
pkg(workspaceLeaf, 'leaf', 2)
const installAnchor = linked ? join(f.root, 'install-link', 'package.json') : f.installAnchor
if (linked) {
rmSync(f.installed, { recursive: true })
symlinkSync(bridge, f.installed, 'junction')
symlinkSync(middle, join(bridge, 'node_modules', 'middle'), 'junction')
symlinkSync(dirname(f.installAnchor), dirname(installAnchor), 'junction')
}
if (origin === 'bundle') {
pkg(dirname(f.installAnchor), 'test-app', 0)
f.profile.layers.push({
packageName: 'bridge', packageDir: f.installed,
patchPath: join(f.installed, 'cordis.patch.yml'), patches: [],
})
}
expect(createRequire(join(bridge, 'node_modules', 'middle', 'package.json'))('leaf')).toEqual({ marker: 1 })
const generation = await createProfileResolutionGeneration({
installAnchor, profile: f.profile, home: f.root,
})
expect(generation.entries.find(entry => entry.name === 'test-app')?.declarer).toBe(f.installAnchor)
expect(generation.entries.find(entry => entry.name === 'middle')?.declarer)
.toBe(join(bridge, 'package.json'))
expect(generation.entries.find(entry => entry.name === 'leaf')).toMatchObject({
packageDir: linked ? workspaceLeaf : logicalLeaf,
declarer: join(middle, 'package.json'),
version: linked ? '2.0.0' : '1.0.0',
})
})
it('keeps each earlier root complete before considering a later root', async () => {
const f = fixture('installation-bridge')
const installedBridge = f.installed