diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index a7326e5860..dce2ecbce0 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -47,7 +47,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur copyFileSync(join(hostDirectory, 'lib', 'index.js'), entry) const pnpm = join(root, 'bundled-pnpm.mjs') const nodeBin = join(root, 'bin') - const child = fork(entry, [root, root, root, 'runtime', pnpm, nodeBin], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }) + const child = fork(entry, [root, root, root, pnpm, nodeBin], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }) let stderr = '' child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk }) const exited = new Promise(resolve => child.once('exit', resolve)) diff --git a/packages/boot/app-boot/src/profile-resolution/legacy-links.ts b/packages/boot/app-boot/src/profile-resolution/legacy-links.ts index 82d63c4a4f..b7d01a5639 100644 --- a/packages/boot/app-boot/src/profile-resolution/legacy-links.ts +++ b/packages/boot/app-boot/src/profile-resolution/legacy-links.ts @@ -1,6 +1,10 @@ -/** Package directory canonicalization for Node and packaged executables. */ +/** Package directory canonicalization and stale profile-projection recognition. */ -import { realpathSync } from 'node:fs' +import { lstatSync, readlinkSync, realpathSync } from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' + +/** Profile-private package links projected into its pnpm-managed node_modules. */ +export const PROFILE_MODULE_FALLBACK_DIR = '.dsh-module-fallback' /** * Return whether the process reads application modules from pkg's virtual filesystem. @@ -18,3 +22,52 @@ export function isPackagedExecutable(): boolean { export function realModuleDirectory(path: string): string { return isPackagedExecutable() ? realpathSync(path) : realpathSync.native(path) } + +/** + * Resolve a link target without following the final path component. + * @param path - candidate path whose parent is canonicalized. + * @returns the canonical candidate, or undefined when its parent is absent. + */ +export function canonicalLinkPath(path: string): string | undefined { + try { + return join(realModuleDirectory(dirname(path)), basename(path)) + } catch (error) { + // A missing parent means the candidate cannot identify an existing owned link. + /* v8 ignore next 2 -- a non-ENOENT realpath failure requires a host filesystem fault */ + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + /* v8 ignore next -- see the host-filesystem exception above */ + throw error + } +} + +/** + * Return whether a symlink or junction points at the same path as `target`. + * @param link - symlink or junction to inspect. + * @param target - expected target path. + * @returns whether both paths identify the same entry. + */ +export function symlinkPointsTo(link: string, target: string): boolean { + const actual = resolve(dirname(link), readlinkSync(link)) + const canonicalActual = canonicalLinkPath(actual) + const canonicalTarget = canonicalLinkPath(resolve(target)) + return canonicalActual !== undefined && canonicalActual === canonicalTarget +} + +/** + * Return whether an observed profile package must not claim local precedence. + * @param profileDir - profile directory containing the package projection. + * @param packageName - bare package name to inspect. + * @returns whether the entry is a managed fallback link or disappeared during inspection. + */ +export function isProfileModuleFallbackLink(profileDir: string, packageName: string): boolean { + const link = join(profileDir, 'node_modules', packageName) + const target = join(profileDir, PROFILE_MODULE_FALLBACK_DIR, 'node_modules', packageName) + try { + return lstatSync(link).isSymbolicLink() && symlinkPointsTo(link, target) + } catch (error) { + /* v8 ignore next 2 -- a vanished candidate cannot claim local precedence */ + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true + /* v8 ignore next -- non-ENOENT lstat/readlink failures require a host filesystem fault */ + throw error + } +} diff --git a/packages/boot/app-boot/src/profile-resolution/resolver.ts b/packages/boot/app-boot/src/profile-resolution/resolver.ts index 788bf44764..e9774e917d 100644 --- a/packages/boot/app-boot/src/profile-resolution/resolver.ts +++ b/packages/boot/app-boot/src/profile-resolution/resolver.ts @@ -7,6 +7,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { getEnvironmentData, setEnvironmentData } from 'node:worker_threads' import type { ModuleLoaderV1, ModuleLoaderV2, ResolveResult } from '@deepseek-ai/cordis-plugin-loader' import { imports as resolvePackageImports, type Package as ResolvePackageManifest } from 'resolve.exports' +import { isProfileModuleFallbackLink } from './legacy-links.ts' import type { ProfileResolutionEntry, ProfileResolutionGeneration } from '../profile.ts' const WORKER_RESOLUTION_KEY = '@deepseek-ai/dsh-app-boot/profile-resolution' @@ -167,14 +168,14 @@ function nativePackageDir(parent: string, name: string): string | undefined { function localPackageCandidate( searchPath: string, name: string, flavor: 'esm' | 'cjs', -): { packageDir: string } | undefined { +): { packageDir: string; canBeManagedLink: boolean } | undefined { const candidate = join(searchPath, name) const stat = statSync(candidate, { throwIfNoEntry: false }) const found = flavor === 'esm' ? stat?.isDirectory() === true : stat !== undefined || ['.js', '.json', '.node'].some(extension => existsSync(candidate + extension)) - return found ? { packageDir: candidate } : undefined + return found ? { packageDir: candidate, canBeManagedLink: stat !== undefined } : undefined } function selfReferenceName(parent: string): string | false | null { @@ -339,13 +340,19 @@ class ResolutionRouter { } const target = generation.entries.get(name) - const candidates: Array<{ packageDir: string }> = [] + const candidates: Array<{ packageDir: string; canBeManagedLink: boolean }> = [] const localSearchPaths: string[] = [] for (const searchPath of createRequire(parent).resolve.paths(name) as string[]) { if (generation.shared.has(resolve(searchPath))) break localSearchPaths.push(searchPath) const candidate = localPackageCandidate(searchPath, name, flavor) - if (candidate !== undefined) candidates.push(candidate) + if (candidate !== undefined) { + const legacy = candidate.canBeManagedLink && generation.profile.some(prefix => ( + candidate.packageDir === join(prefix, 'node_modules', name) + && isProfileModuleFallbackLink(prefix.slice(0, -1), name) + )) + if (!legacy) candidates.push(candidate) + } } if (candidates.length > 0) { if (flavor === 'cjs' && nativeResolve !== undefined) { @@ -379,7 +386,7 @@ class ResolutionRouter { const eligible = target?.scope === 'installation' || (target?.scope === 'profile' && parentRoutes.activeProfile) - const after = join(profilesDir, 'package.json') + const after = join(dirname(profilesDir), 'package.json') const route: ResolutionRoute = eligible ? { kind: 'fallback', entry: target, after } : { kind: 'after-fallback', parent: after } diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index b343acec34..0528ce189c 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -21,14 +21,16 @@ */ import { createRequire } from 'node:module' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { basename, dirname, join } from 'node:path' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { DshPackageManifest } from '@deepseek-ai/dsh-package-manifest' import { loadOverlayPatches } from './index.ts' -import { realModuleDirectory } from './profile-resolution/legacy-links.ts' +import { + canonicalLinkPath, isProfileModuleFallbackLink, PROFILE_MODULE_FALLBACK_DIR, realModuleDirectory, symlinkPointsTo, +} from './profile-resolution/legacy-links.ts' /** Directory under the Harness home holding every profile. */ export const PROFILES_DIR = 'profiles' @@ -213,7 +215,7 @@ function resolveModuleFallbackEntries( declarers: ReadonlyMap versions: ReadonlyMap } { - // tsx skips workspace path mappings for importers under node_modules. + // Real declaring paths keep workspace symlinks under node_modules from disabling tsx path mappings. const canonicalAnchor = join(realModuleDirectory(dirname(installAnchor)), basename(installAnchor)) const appManifest = readModuleFallbackManifest(canonicalAnchor) const links = new Map() @@ -326,13 +328,15 @@ function installedProfilePackageNames(profile: Profile, manifest: ProfileManifes if (manifest === undefined) return [] return profileDependencyNames(manifest).filter((name) => { const candidate = join(profile.dir, 'node_modules', name) - return existsSync(join(candidate, 'package.json')) + if (!existsSync(join(candidate, 'package.json'))) return false + return !isProfileModuleFallbackLink(profile.dir, name) }) } /** Collect the first resolvable package directory for each dependency name. */ function dependencyClosure( anchors: readonly string[], reserved: ReadonlySet, + exclude: (candidate: string, packageName: string) => boolean, declarers?: Map, versions?: Map, ): Map { @@ -355,7 +359,7 @@ function dependencyClosure( /* v8 ignore next -- an installable package manifest always declares dependencies or peers */ for (const dep of profileDependencyNames(next.manifest)) { if (visited.has(dep)) continue - const dir = packageDirFromAnchor(next.anchor, dep) + const dir = packageDirFromAnchor(next.anchor, dep, exclude) // A declared-but-uninstalled dependency cannot be loader-visible. if (dir === undefined) continue visited.add(dep) @@ -377,10 +381,25 @@ function resolveProfileModuleFallback( declarers?: Map, versions?: Map, ): Map { + const profileModulesDir = join(profile.dir, 'node_modules') + const ownedModulesDir = join(profile.dir, PROFILE_MODULE_FALLBACK_DIR, 'node_modules') const bundleAnchors = profile.layers .filter(layer => !installationPackageNames.has(layer.packageName)) .map(layer => join(layer.packageDir, 'package.json')) - const bundleLinks = dependencyClosure(bundleAnchors, installationPackageNames, declarers, versions) + const bundleLinks = dependencyClosure(bundleAnchors, installationPackageNames, (candidate, packageName) => { + const profileLink = join(profileModulesDir, packageName) + if (canonicalLinkPath(candidate) !== canonicalLinkPath(profileLink)) return false + try { + return lstatSync(profileLink).isSymbolicLink() + && symlinkPointsTo(profileLink, join(ownedModulesDir, packageName)) + } catch (error) { + // A concurrent cleanup may remove the projection after package discovery. + /* v8 ignore next 2 -- a non-ENOENT lstat failure requires a host filesystem fault */ + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true + /* v8 ignore next -- see the host-filesystem exception above */ + throw error + } + }, declarers, versions) for (const layer of profile.layers) bundleLinks.delete(layer.packageName) return bundleLinks } @@ -456,12 +475,13 @@ function normalizeShippedProfile(name: string, dir: string, manifest: ProfileMan */ function packageDirFromAnchor( anchor: string, packageName: string, + exclude: (candidate: string, packageName: string) => boolean = () => false, ): string | undefined { // resolve.paths returns null only for builtins, which no bundle name is. /* v8 ignore next */ for (const searchPath of createRequire(anchor).resolve.paths(packageName) ?? []) { const candidate = join(searchPath, packageName) - if (existsSync(join(candidate, 'package.json'))) return candidate + if (existsSync(join(candidate, 'package.json')) && !exclude(candidate, packageName)) return candidate } return undefined } diff --git a/packages/boot/app-boot/tests/profile-resolution.spec.ts b/packages/boot/app-boot/tests/profile-resolution.spec.ts index bf1f586a45..60dba4a1b1 100644 --- a/packages/boot/app-boot/tests/profile-resolution.spec.ts +++ b/packages/boot/app-boot/tests/profile-resolution.spec.ts @@ -621,7 +621,7 @@ describe('profile resolution generation', { concurrent: false }, () => { imports: { '#local': 'local-import', '#ancestor': 'ancestor-import' }, })) const local = join(f.profile.dir, 'node_modules', 'local-import') - const ancestor = join(f.root, 'profiles', 'node_modules', 'ancestor-import') + const ancestor = join(f.root, 'node_modules', 'ancestor-import') pkg(local, 'local-import', 4) pkg(ancestor, 'ancestor-import', 5) const require = createRequire(join(f.profile.dir, 'entry.cjs')) @@ -983,6 +983,84 @@ describe('profile resolution generation', { concurrent: false }, () => { })).toBe(alternativeSubpath) }) + it('skips stale shared and profile-owned fallback entries when the generation misses', async () => { + const f = fixture() + pkg(join(f.root, 'profiles', 'node_modules', 'stale-shared'), 'stale-shared', 9) + pkg(join(f.root, 'node_modules', 'stale-shared'), 'stale-shared', 3) + const target = join(f.root, 'stale-private-target') + const owned = join(f.profile.dir, '.dsh-module-fallback', 'node_modules', 'stale-private') + const projected = join(f.profile.dir, 'node_modules', 'stale-private') + pkg(target, 'stale-private', 9) + mkdirSync(dirname(owned), { recursive: true }) + symlinkSync(target, owned, process.platform === 'win32' ? 'junction' : 'dir') + mkdirSync(dirname(projected), { recursive: true }) + symlinkSync(owned, projected, process.platform === 'win32' ? 'junction' : 'dir') + pkg(join(f.root, 'node_modules', 'stale-private'), 'stale-private', 3) + const registration = installProfileResolution(await generationOf(f)) + registrations.push(registration) + const require = createRequire(join(f.profile.dir, 'entry.cjs')) + for (const name of ['stale-shared', 'stale-private']) { + expect(require(name)).toEqual({ marker: 3 }) + expect(require.resolve(name, { paths: [f.profile.dir] })).toBe(join(f.root, 'node_modules', name, 'index.cjs')) + expect(await importFrom(name, pathToFileURL(join(f.profile.dir, `${name}.mjs`)).href)) + .toMatchObject({ marker: 3 }) + } + }) + + it('skips stale shared and declared profile projections when the generation supplies the package', async () => { + const f = fixture() + file(join(f.profile.dir, 'package.json'), JSON.stringify({ + name: 'test-profile', private: true, dependencies: { 'resolution-lib': '*' }, + imports: { '#library': 'resolution-lib' }, + })) + const target = join(f.root, 'stale-target') + pkg(target, 'resolution-lib', 9) + const owned = join(f.profile.dir, '.dsh-module-fallback', 'node_modules', 'resolution-lib') + const projected = join(f.profile.dir, 'node_modules', 'resolution-lib') + const shared = join(f.root, 'profiles', 'node_modules', 'resolution-lib') + mkdirSync(dirname(owned), { recursive: true }) + mkdirSync(dirname(projected), { recursive: true }) + mkdirSync(dirname(shared), { recursive: true }) + symlinkSync(target, owned, process.platform === 'win32' ? 'junction' : 'dir') + symlinkSync(owned, projected, process.platform === 'win32' ? 'junction' : 'dir') + symlinkSync(target, shared, process.platform === 'win32' ? 'junction' : 'dir') + const generation = await generationOf(f) + expect(generation.localPackageNames).toEqual([]) + const registration = installProfileResolution(generation) + registrations.push(registration) + const require = createRequire(join(f.profile.dir, 'entry.cjs')) + for (const name of ['resolution-lib', '#library']) { + expect(require(name)).toEqual({ marker: 1 }) + expect(await importFrom(name, pathToFileURL(join(f.profile.dir, 'entry.mjs')).href)) + .toMatchObject({ marker: 1 }) + } + expect(realpathSync(projected)).toBe(target) + expect(realpathSync(shared)).toBe(target) + }) + + it('excludes profile-owned projections from selected bundle dependency traversal', async () => { + const f = fixture() + const bundle = join(f.profile.dir, 'node_modules', 'selected-bundle') + pkg(bundle, 'selected-bundle', 1, { 'bundle-leaf': '*' }) + f.profile.layers.push({ + packageName: 'selected-bundle', packageDir: bundle, + patchPath: join(bundle, 'cordis.patch.yml'), patches: [], + }) + const target = join(f.root, 'stale-leaf') + const owned = join(f.profile.dir, '.dsh-module-fallback', 'node_modules', 'bundle-leaf') + const projected = join(f.profile.dir, 'node_modules', 'bundle-leaf') + pkg(target, 'bundle-leaf', 9) + mkdirSync(dirname(owned), { recursive: true }) + symlinkSync(target, owned, process.platform === 'win32' ? 'junction' : 'dir') + symlinkSync(owned, projected, process.platform === 'win32' ? 'junction' : 'dir') + const ancestor = join(f.root, 'node_modules', 'bundle-leaf') + pkg(ancestor, 'bundle-leaf', 3) + const generation = await generationOf(f) + expect(generation.entries.find(entry => entry.name === 'bundle-leaf')) + .toMatchObject({ packageDir: ancestor, version: '3.0.0', scope: 'profile' }) + expect(realpathSync(projected)).toBe(target) + }) + it('continues after a canonicalized profiles directory from the matching parent tree', async () => { const root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-profile-generation-symlink-'))) roots.push(root) @@ -993,10 +1071,10 @@ describe('profile resolution generation', { concurrent: false }, () => { mkdirSync(realProfileDir, { recursive: true }) mkdirSync(dirname(profilesDir), { recursive: true }) symlinkSync(realProfilesDir, profilesDir, process.platform === 'win32' ? 'junction' : 'dir') - const parentPackage = join(realProfilesDir, 'node_modules', 'parent-lib') - pkg(parentPackage, 'parent-lib', 9) - pkg(join(carrier, 'node_modules', 'ancestor-lib'), 'ancestor-lib', 3) - pkg(join(root, 'home', 'node_modules', 'ancestor-lib'), 'ancestor-lib', 4) + pkg(join(realProfilesDir, 'node_modules', 'stale-only'), 'stale-only', 9) + const ancestor = join(carrier, 'node_modules', 'stale-only') + pkg(ancestor, 'stale-only', 3) + pkg(join(root, 'home', 'node_modules', 'stale-only'), 'stale-only', 4) const registration = installProfileResolution({ profilesDir, profileDir: join(profilesDir, 'test'), @@ -1005,11 +1083,10 @@ describe('profile resolution generation', { concurrent: false }, () => { }) registrations.push(registration) const require = createRequire(join(realProfileDir, 'entry.cjs')) - expect(require('parent-lib')).toEqual({ marker: 9 }) - expect(require('ancestor-lib')).toEqual({ marker: 3 }) + expect(require('stale-only')).toEqual({ marker: 3 }) const parent = pathToFileURL(join(realProfileDir, 'entry.mjs')).href - expect(await importFrom('parent-lib', parent)).toMatchObject({ marker: 9 }) - expect(registration.packageDir('parent-lib', parent)).toBe(parentPackage) + expect(await importFrom('stale-only', parent)).toMatchObject({ marker: 3 }) + expect(registration.packageDir('stale-only', parent)).toBe(ancestor) }) it('leaves conditional exports to Node', async () => { diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index a694334769..d512e9565d 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -36,7 +36,7 @@ afterAll(() => { }) const tmp = (): string => { - const dir = mkdtempSync(join(tmpdir(), 'dsh-profile-')) + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-profile-'))) tempRoots.push(dir) return dir }