diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index dbaf904569..1cd1c1636c 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -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) diff --git a/apps/cli/tests/fixtures/source-tool-driver.ts b/apps/cli/tests/fixtures/source-tool-driver.ts new file mode 100644 index 0000000000..c448f4320d --- /dev/null +++ b/apps/cli/tests/fixtures/source-tool-driver.ts @@ -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 => { + 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') +} diff --git a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts new file mode 100644 index 0000000000..c20531970c --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts @@ -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, files: Record): Promise { + 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 }) + } + } + }) +} diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts index 975e4c3591..e0deebc906 100644 --- a/apps/cli/tests/source-launch.compat.spec.ts +++ b/apps/cli/tests/source-launch.compat.spec.ts @@ -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 @@ -39,4 +47,84 @@ describe('dsh SOURCE launcher (node --import tsx/esm)', () => { expect(result.stderr).toContain('--profile 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) }) diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index ce504353f4..b343acec34 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -213,19 +213,21 @@ function resolveModuleFallbackEntries( declarers: ReadonlyMap versions: ReadonlyMap } { - 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() const declarers = new Map() const versions = new Map() /* 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 }) diff --git a/packages/boot/app-boot/tests/profile-resolution.spec.ts b/packages/boot/app-boot/tests/profile-resolution.spec.ts index 29630006e8..bf1f586a45 100644 --- a/packages/boot/app-boot/tests/profile-resolution.spec.ts +++ b/packages/boot/app-boot/tests/profile-resolution.spec.ts @@ -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