diff --git a/dsh-plugin-desktop/package.json b/dsh-plugin-desktop/package.json index 8979541e70..a42933ad9b 100644 --- a/dsh-plugin-desktop/package.json +++ b/dsh-plugin-desktop/package.json @@ -285,20 +285,13 @@ "build": { "appId": "ai.deepseek.dsh.desktop", "productName": "DSH Desktop", - "asar": { - "smartUnpack": false - }, + "asar": true, "asarUnpack": [ - "lib/diagnostic-export-worker.js", - "node_modules/@deepseek-ai/dsh/config/agent-presets/**", - "node_modules/@deepseek-ai/node-addon-landlock-run-linux-*/**", - "node_modules/@img/sharp-*/**", - "node_modules/@img/sharp-libvips-*/**", - "node_modules/@koromix/koffi-*/**", - "node_modules/@vscode/ripgrep-*/**", - "node_modules/node-addon-require-builtin-*/**", - "node_modules/node-pty/prebuilds/**", - "node_modules/pnpm/dist/vendor/**" + "package.json", + "cordis.patch.yml", + "build/**", + "lib/**", + "node_modules/**" ], "afterPack": "./scripts/verify-packaged-runtime.ts", "electronFuses": { diff --git a/dsh-plugin-desktop/scripts/verify-packaged-runtime.ts b/dsh-plugin-desktop/scripts/verify-packaged-runtime.ts index e899f2801a..329171653a 100644 --- a/dsh-plugin-desktop/scripts/verify-packaged-runtime.ts +++ b/dsh-plugin-desktop/scripts/verify-packaged-runtime.ts @@ -1,8 +1,9 @@ /** Fail-loud verification of the runtime entries sealed into Electron's app.asar. */ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { isAbsolute, join, relative, sep } from 'node:path' import { Worker } from 'node:worker_threads' import { listPackage } from '@electron/asar' import AdmZip from 'adm-zip' @@ -56,12 +57,39 @@ export const REQUIRED_PACKAGED_RUNTIME_ENTRIES = [ 'node_modules/pnpm/bin/pnpm.mjs', ] as const -/** Platform-independent files consumed by an API that requires a physical path. */ +/** Physical entries required because profile fallback symlinks cannot target ASAR paths. */ export const REQUIRED_UNPACKED_RUNTIME_ENTRIES = [ + 'package.json', + 'cordis.patch.yml', + 'build/app-icon.png', + 'build/app-icon-mac.png', + 'build/tray-iconTemplate.png', + 'build/tray-icon-blue.png', + 'lib/main.js', + 'lib/client.js', + 'lib/native-ui/profile-create.html', + 'lib/native-ui/recovery.html', + 'lib/index.js', + 'lib/profile.js', + 'lib/profile-manager.js', + 'lib/profile-service.js', + 'lib/pnpm.js', + 'lib/profiles.js', + 'lib/diagnostics.js', 'lib/diagnostic-export-worker.js', + 'lib/terminal.js', + 'lib/update-download.js', + 'lib/updates.js', + 'lib/windows-agent-presets.js', + 'lib/windows-pwsh-sandbox.js', + 'node_modules/@deepseek-ai/dsh/package.json', 'node_modules/@deepseek-ai/dsh/config/agent-presets/cordis/agent.cordis.yml', 'node_modules/@deepseek-ai/dsh/config/agent-presets/cordis/skills/cordis-plugin-development/SKILL.md', 'node_modules/@deepseek-ai/dsh/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md', + 'node_modules/@deepseek-ai/dsh/lib/bin.js', + 'node_modules/@deepseek-ai/dsh-app-boot/lib/index.js', + 'node_modules/@deepseek-ai/dsh-web-frontend/dist/index.html', + 'node_modules/pnpm/bin/pnpm.mjs', ] as const /** Prebuilt Node-API modules required when the Windows package skips native source rebuilds. */ @@ -72,17 +100,6 @@ export const REQUIRED_WINDOWS_X64_NODE_PTY_ENTRIES = [ 'node_modules/node-pty/prebuilds/win32-x64/conpty/conpty.dll', ] as const -/** Other native executables and libraries required by the Windows x64 package. */ -export const REQUIRED_WINDOWS_X64_NATIVE_ENTRIES = [ - 'node_modules/@img/sharp-win32-x64/lib/libvips-42.dll', - 'node_modules/@img/sharp-win32-x64/lib/libvips-cpp-8.18.3.dll', - 'node_modules/@img/sharp-win32-x64/lib/sharp-win32-x64-0.35.3.node', - 'node_modules/@koromix/koffi-win32-x64/win32_x64/koffi.node', - 'node_modules/@vscode/ripgrep-win32-x64/bin/rg.exe', - 'node_modules/node-addon-require-builtin-win32-x64-msvc/prebuilt/win32-x64-msvc-napi-v9.node', - 'node_modules/pnpm/dist/vendor/fastlist-0.3.0-x64.exe', -] as const - /** Native and build-only trees that must not inflate a Windows x64 installation. */ export const FORBIDDEN_WINDOWS_X64_RUNTIME_PREFIXES = [ 'node_modules/@vscode/ripgrep-win32-arm64/', @@ -105,12 +122,35 @@ export const REQUIRED_MACOS_UNIVERSAL_ENTRIES = [ ...MACOS_UNIVERSAL_NATIVE_ENTRIES.map(entry => entry.path), ] as const +/** Package exports that profile fallback links must resolve from the physical application tree. */ +export const REQUIRED_UNPACKED_PACKAGE_SPECIFIERS = [ + 'dsh-plugin-desktop', + 'dsh-plugin-desktop/profile', + 'dsh-plugin-desktop/client', + 'dsh-plugin-desktop/terminal', + 'dsh-plugin-desktop/pnpm', + 'dsh-plugin-desktop/profile-service', + 'dsh-plugin-desktop/profiles', + 'dsh-plugin-desktop/diagnostics', + 'dsh-plugin-desktop/notifications', + 'dsh-plugin-desktop/updates', + 'dsh-plugin-desktop/windows-agent-presets', + 'dsh-plugin-desktop/windows-pwsh-sandbox', + 'dsh-plugin-desktop/package.json', + '@deepseek-ai/dsh-base/package.json', + '@deepseek-ai/schemastery/package.json', + '@deepseek-ai/dsh-web-app/package.json', +] as const + /** Injectable archive listing seam used by focused tests. */ export type ArchiveLister = (archivePath: string, options: { isPack: boolean }) => readonly string[] /** Injectable physical-file probe used by focused tests. */ export type FileProbe = (filename: string) => boolean +/** Injectable Node package resolver used by focused tests. */ +export type PackageResolver = (specifier: string) => string + /** Inputs understood by the bundled diagnostics Worker. */ export interface PackagedDiagnosticWorkerData { readonly logsDir: string @@ -284,72 +324,81 @@ export function verifyPackagedAsar( } /** - * Return whether one archive entry is allowed to exist beside app.asar. - * Parent directories are included because Electron Builder materializes them - * while unpacking a whitelisted descendant. + * Require every ASAR header entry to have a physical counterpart. + * + * The Desktop packaging contract unpacks every included application file so + * profile fallback links and Node ESM resolution never target virtual paths. + * Checking the complete header closes the gap left by a curated entry list: + * a collector regression cannot silently omit transitive packages such as + * yaml, zod, or typebox from app.asar.unpacked. */ -export function allowedUnpackedRuntimeEntry(entry: string): boolean { - const exact = new Set([ - ...REQUIRED_UNPACKED_RUNTIME_ENTRIES, - ...REQUIRED_WINDOWS_X64_NODE_PTY_ENTRIES, - ...REQUIRED_WINDOWS_X64_NATIVE_ENTRIES, - ...REQUIRED_MACOS_UNIVERSAL_ENTRIES, - ]) - const prefixes = [ - 'node_modules/@deepseek-ai/dsh/config/agent-presets/', - 'node_modules/@deepseek-ai/node-addon-landlock-run-linux-', - 'node_modules/@img/sharp-', - 'node_modules/@img/sharp-libvips-', - 'node_modules/@koromix/koffi-', - 'node_modules/@vscode/ripgrep-', - 'node_modules/node-addon-require-builtin-', - 'node_modules/node-pty/prebuilds/', - 'node_modules/pnpm/dist/vendor/', - ] - if (exact.has(entry) || prefixes.some(prefix => entry.startsWith(prefix))) return true - const directory = entry.length === 0 ? '' : `${entry}/` - return [...exact].some(path => path.startsWith(directory)) - || prefixes.some(prefix => prefix.startsWith(directory)) -} - -/** Reject ordinary JavaScript and resources leaking back into app.asar.unpacked. */ -export function verifyUnpackedRuntimeScope( +export function verifyUnpackedArchiveMirror( archiveEntries: ReadonlySet, unpackedRoot: string, exists: FileProbe = existsSync, ): void { - const unexpected = [...archiveEntries].filter(entry => - entry.length > 0 - && exists(join(unpackedRoot, entry)) - && !allowedUnpackedRuntimeEntry(entry), - ) - if (unexpected.length > 0) { + const missing = [...archiveEntries] + .filter(entry => entry.length > 0 && !exists(join(unpackedRoot, entry))) + if (missing.length > 0) { throw new Error( - `dsh-plugin-desktop: unpacked runtime at ${unpackedRoot} contains non-whitelisted physical entries: ${unexpected.join(', ')}`, + `dsh-plugin-desktop: unpacked runtime at ${unpackedRoot} is missing ASAR-declared physical entries: ${missing.join(', ')}`, ) } } +/** + * Verify package exports resolve through the physical tree instead of the build workspace. + * @param unpackedRoot - absolute path to app.asar.unpacked. + * @param resolvePackage - package resolver anchored at the physical root manifest. + * @returns Nothing; failure rejects missing exports and paths outside app.asar.unpacked. + */ +export function verifyUnpackedPackageResolution( + unpackedRoot: string, + resolvePackage: PackageResolver = createRequire(join(unpackedRoot, 'package.json')).resolve, +): void { + for (const specifier of REQUIRED_UNPACKED_PACKAGE_SPECIFIERS) { + let resolvedPath: string + try { + resolvedPath = resolvePackage(specifier) + } catch (cause) { + throw new Error( + `dsh-plugin-desktop: packaged runtime at ${unpackedRoot} cannot resolve required package export ${specifier}`, + { cause }, + ) + } + + const relativePath = relative(unpackedRoot, resolvedPath) + if ( + !isAbsolute(resolvedPath) + || relativePath === '..' + || relativePath.startsWith(`..${sep}`) + || isAbsolute(relativePath) + ) { + throw new Error( + `dsh-plugin-desktop: required package export ${specifier} resolved outside ${unpackedRoot}: ${resolvedPath}`, + ) + } + } +} + /** * Verify Electron Builder's completed application before signing begins. * @param context - Electron Builder's afterPack context. * @param list - ASAR listing implementation. * @param exists - physical-file probe for the unpacked CLI dependency tree. + * @param resolvePackage - package resolver anchored at the physical root manifest. * @returns Nothing; failure rejects the package before signing. */ export function verifyPackagedRuntime( context: PackagedRuntimeContext, list: ArchiveLister = listPackage, exists: FileProbe = existsSync, + resolvePackage?: PackageResolver, ): void { const archiveEntries = verifyPackagedAsar(resolvePackagedAsarPath(context), list) const unpackedRoot = resolvePackagedUnpackedRoot(context) const requiredPhysicalEntries = context.electronPlatformName === 'win32' - ? [ - ...REQUIRED_UNPACKED_RUNTIME_ENTRIES, - ...REQUIRED_WINDOWS_X64_NODE_PTY_ENTRIES, - ...REQUIRED_WINDOWS_X64_NATIVE_ENTRIES, - ] + ? [...REQUIRED_UNPACKED_RUNTIME_ENTRIES, ...REQUIRED_WINDOWS_X64_NODE_PTY_ENTRIES] : context.electronPlatformName === 'darwin' && context.arch === 4 ? [...REQUIRED_UNPACKED_RUNTIME_ENTRIES, ...REQUIRED_MACOS_UNIVERSAL_ENTRIES] : REQUIRED_UNPACKED_RUNTIME_ENTRIES @@ -378,7 +427,8 @@ export function verifyPackagedRuntime( ) } } - verifyUnpackedRuntimeScope(archiveEntries, unpackedRoot, exists) + verifyUnpackedArchiveMirror(archiveEntries, unpackedRoot, exists) + verifyUnpackedPackageResolution(unpackedRoot, resolvePackage) } /** diff --git a/dsh-plugin-desktop/src/module-resolution.ts b/dsh-plugin-desktop/src/module-resolution.ts index 76bc51d338..9e52d04f7a 100644 --- a/dsh-plugin-desktop/src/module-resolution.ts +++ b/dsh-plugin-desktop/src/module-resolution.ts @@ -1,7 +1,8 @@ /** Profile-relative package resolution for Electron's restricted Node runtime. */ import Module, { registerHooks } from 'node:module' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { unpackedAsarPath } from './packaged-runtime-path.ts' import { findOverlayPackage, packageNameFromSpecifier, @@ -9,8 +10,12 @@ import { } from './package-overlay.ts' const LOADER_ENTRY_URL = import.meta.resolve('@deepseek-ai/cordis-plugin-loader') -const DESKTOP_ENTRY_URL = new URL('../lib/index.js', import.meta.url).href -const DESKTOP_PACKAGE_URL = new URL('../package.json', import.meta.url).href +const DESKTOP_ENTRY_URL = pathToFileURL( + unpackedAsarPath(fileURLToPath(new URL('../lib/index.js', import.meta.url))), +).href +const DESKTOP_PACKAGE_URL = pathToFileURL( + unpackedAsarPath(fileURLToPath(new URL('../package.json', import.meta.url))), +).href interface CommonJsModuleResolver { _resolveFilename( @@ -33,10 +38,6 @@ function isBareSpecifier(specifier: string): boolean { return !specifier.startsWith('.') && !specifier.startsWith('/') && !URL.canParse(specifier) } -function overlayParentUrl(source: 'install' | 'profile', profileBaseUrl: string): string { - return source === 'profile' ? profileBaseUrl : DESKTOP_ENTRY_URL -} - /** * Resolve Cordis Loader bare imports from the selected persistent profile. * @param profileBaseUrl - file URL inside the profile that owns plugin dependencies. @@ -88,7 +89,7 @@ export function installProfilePackageResolver(profileBaseUrl: string): () => voi }) const resolved = nextResolve(specifier, { ...context, - parentURL: overlayParentUrl(overlay.selected.source, profileBaseUrl), + parentURL: overlay.selected.source === 'profile' ? profileBaseUrl : DESKTOP_ENTRY_URL, }) overlayModuleUrls.add(resolved.url) return resolved @@ -107,18 +108,7 @@ export function installProfilePackageResolver(profileBaseUrl: string): () => voi return resolved } catch (cause) { if ((cause as NodeJS.ErrnoException).code !== 'ERR_MODULE_NOT_FOUND') throw cause - const packageName = packageNameFromSpecifier(specifier) - const overlay = packageName === undefined - ? undefined - : findOverlayPackage(packageName, { - installPackageUrl: DESKTOP_PACKAGE_URL, - profilePackageUrl: profileBaseUrl, - }) - if (overlay === undefined) throw cause - const resolved = nextResolve(specifier, { - ...context, - parentURL: overlayParentUrl(overlay.selected.source, profileBaseUrl), - }) + const resolved = nextResolve(specifier, { ...context, parentURL: profileBaseUrl }) overlayModuleUrls.add(resolved.url) return resolved } diff --git a/dsh-plugin-desktop/src/packaged-runtime-path.ts b/dsh-plugin-desktop/src/packaged-runtime-path.ts index 7539027ed9..253b52ce41 100644 --- a/dsh-plugin-desktop/src/packaged-runtime-path.ts +++ b/dsh-plugin-desktop/src/packaged-runtime-path.ts @@ -1,4 +1,4 @@ -/** Runtime-path helpers for Electron ASAR dependencies and explicitly unpacked assets. */ +/** Physical-path helpers for dependencies Electron Builder removes from app.asar. */ import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' @@ -46,7 +46,7 @@ export function unpackedAsarPath(filename: string): string { * Resolve one dependency entry from a built desktop module. * @param moduleUrl - URL of a module emitted below the package's `lib` directory. * @param dependencyEntry - package subpath resolved through Node module lookup. - * @returns a logical path suitable for Electron's RunAsNode module loader. + * @returns a physical path suitable for Node execution and profile symlinks. */ export function packagedDependencyPath(moduleUrl: string, dependencyEntry: string): string { const segments = dependencyEntry.split('/') @@ -63,5 +63,5 @@ export function packagedDependencyPath(moduleUrl: string, dependencyEntry: strin resolvePackageRoot(moduleUrl, packageName), ...segments.slice(packageSegments), ) - return logicalPath + return unpackedAsarPath(logicalPath) } diff --git a/dsh-plugin-desktop/src/profile.ts b/dsh-plugin-desktop/src/profile.ts index ed4d774c46..1d47e4d854 100644 --- a/dsh-plugin-desktop/src/profile.ts +++ b/dsh-plugin-desktop/src/profile.ts @@ -8,6 +8,7 @@ import { evaluate, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugi import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { composeEntries, + healProfilesModuleFallback, initProfile, loadOptionalPatches, loadOverlayPatches, @@ -54,7 +55,7 @@ const BIN_NAME = DESKTOP_PACKAGE_NAME const REQUIRED_BUNDLES = requiredWebBundles() const REQUIRED_BUNDLE_SET = new Set(REQUIRED_BUNDLES) const OBSOLETE_DESKTOP_BUNDLE_SET = new Set(['@deepseek-ai/dsh-desktop-app']) -const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.meta.url)) +const INSTALL_ANCHOR = unpackedAsarPath(fileURLToPath(new URL('../package.json', import.meta.url))) const DESKTOP_PATCH_PATH = fileURLToPath(new URL('../cordis.patch.yml', import.meta.url)) const DIRECTORY_PICKER_ROW_ID = 'directory-picker' const AUTO_PICKER_PACKAGE = '@deepseek-ai/dsh-host-directory-picker-auto' @@ -582,6 +583,7 @@ export function prepareDesktopProfile( const profileDir = profileName === DESKTOP_PROFILE_NAME ? ensureDesktopProfile(home) : resolveProfileDir(profileName, home) + healProfilesModuleFallback(INSTALL_ANCHOR, home) // `plugin-management` is the community market's user-facing scope. Startup // recovery has its own state file so switching to another provider cannot // reapply a stale community-market disable, while a recovery disable always diff --git a/dsh-plugin-desktop/tests/desktop-cli.spec.ts b/dsh-plugin-desktop/tests/desktop-cli.spec.ts index cc44699011..09ad784199 100644 --- a/dsh-plugin-desktop/tests/desktop-cli.spec.ts +++ b/dsh-plugin-desktop/tests/desktop-cli.spec.ts @@ -182,7 +182,7 @@ describe('packaged dsh bootstrap', () => { } }) - it('maps only explicitly physical assets to the unpacked Electron tree', () => { + it('uses the physical unpacked dependency tree only inside an Electron package', () => { expect(unpackedAsarPath('/Applications/DSH Desktop.app/Contents/Resources/app.asar/node_modules/pkg')) .toBe('/Applications/DSH Desktop.app/Contents/Resources/app.asar.unpacked/node_modules/pkg') expect(unpackedAsarPath('C:\\Program Files\\DSH Desktop\\resources\\app.asar\\node_modules\\pkg')) @@ -194,7 +194,7 @@ describe('packaged dsh bootstrap', () => { .toThrow('relative POSIX path') }) - it('keeps a resolved JavaScript dependency inside ASAR for RunAsNode', () => { + it('maps a resolved ASAR dependency to its physical unpacked path', () => { const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-asar-profile-')) const desktopLib = join(root, 'app.asar', 'lib') const dshPackage = join(root, 'app.asar', 'node_modules', '@deepseek-ai', 'dsh') @@ -210,7 +210,7 @@ describe('packaged dsh bootstrap', () => { const moduleUrl = pathToFileURL(join(desktopLib, 'desktop-cli.js')).href expect(packagedDependencyPath(moduleUrl, '@deepseek-ai/dsh/lib/bin.js')).toBe(join( realpathSync(root), - 'app.asar', + 'app.asar.unpacked', 'node_modules', '@deepseek-ai', 'dsh', diff --git a/dsh-plugin-desktop/tests/module-resolution.spec.ts b/dsh-plugin-desktop/tests/module-resolution.spec.ts index 154da26419..57679bd79b 100644 --- a/dsh-plugin-desktop/tests/module-resolution.spec.ts +++ b/dsh-plugin-desktop/tests/module-resolution.spec.ts @@ -154,27 +154,6 @@ describe('installProfilePackageResolver', () => { expect(harness.resolve?.('profile-peer', { parentURL: desktopPluginUrl }, nextResolve)).toEqual({ url: profilePeerUrl }) }) - it('allows a Profile-selected package to use a missing dependency from the Desktop archive', () => { - const profileBaseUrl = 'file:///C:/Users/test/profile/package.json' - const profilePluginUrl = 'file:///C:/Users/test/profile/node_modules/plugin/index.js' - const desktopPeerUrl = 'file:///Applications/DSH.app/Contents/Resources/app.asar/node_modules/desktop-peer/index.js' - harness.sources.set('plugin', 'profile') - harness.sources.set('desktop-peer', 'install') - installProfilePackageResolver(profileBaseUrl) - const nextResolve = vi.fn((specifier: string, context: { parentURL?: string }) => { - if (specifier === 'plugin' && context.parentURL === profileBaseUrl) return { url: profilePluginUrl } - if (specifier === 'desktop-peer' && context.parentURL?.endsWith('/lib/index.js')) { - return { url: desktopPeerUrl } - } - throw missing(specifier, context.parentURL) - }) - const loaderEntryUrl = import.meta.resolve('@deepseek-ai/cordis-plugin-loader') - - expect(harness.resolve?.('plugin', { parentURL: loaderEntryUrl }, nextResolve)).toEqual({ url: profilePluginUrl }) - expect(harness.resolve?.('desktop-peer', { parentURL: profilePluginUrl }, nextResolve)) - .toEqual({ url: desktopPeerUrl }) - }) - it('does not expose Profile dependencies to unrelated modules', () => { const profileBaseUrl = 'file:///C:/Users/test/profile/package.json' installProfilePackageResolver(profileBaseUrl) @@ -210,7 +189,7 @@ describe('installProfilePackageResolver', () => { )).toBe('ordinary:@deepseek-ai/dsh-client-modules/package.json') expect(resolveFilename( '@deepseek-ai/dsh-client-modules/client.js', - { filename: profileManifestPath }, + { filename: '/tmp/dsh-profile/package.json' }, false, )).toBe('ordinary:@deepseek-ai/dsh-client-modules/client.js') diff --git a/dsh-plugin-desktop/tests/package.spec.ts b/dsh-plugin-desktop/tests/package.spec.ts index f94468839f..d382c2f592 100644 --- a/dsh-plugin-desktop/tests/package.spec.ts +++ b/dsh-plugin-desktop/tests/package.spec.ts @@ -29,7 +29,6 @@ const manifest = JSON.parse(readFileSync(new URL('package.json', packageRoot), ' build?: { productName?: unknown appId?: unknown - asar?: unknown asarUnpack?: unknown afterPack?: unknown electronFuses?: unknown @@ -373,7 +372,6 @@ describe('published package surface', () => { expect(config).toContain("diagnostics: 'src/diagnostics.ts'") expect(config).toContain("notifications: 'src/notifications.ts'") expect(config).toContain("'diagnostic-export-worker': 'src/diagnostic-export-worker.ts'") - expect(config).toContain('codeSplitting: false') expect(config).toContain("entry: { preload: 'src/preload.ts' }") expect(config).toContain("entryFileNames: 'preload.cjs'") expect(config).toContain("terminal: 'src/terminal.ts'") @@ -569,18 +567,12 @@ describe('published package surface', () => { expect(manifest.version).toBe(workspaceManifest.version) expect(manifest.build?.productName).toBe('DSH Desktop') expect(manifest.build?.appId).toBe('ai.deepseek.dsh.desktop') - expect(manifest.build?.asar).toEqual({ smartUnpack: false }) expect(manifest.build?.asarUnpack).toEqual([ - 'lib/diagnostic-export-worker.js', - 'node_modules/@deepseek-ai/dsh/config/agent-presets/**', - 'node_modules/@deepseek-ai/node-addon-landlock-run-linux-*/**', - 'node_modules/@img/sharp-*/**', - 'node_modules/@img/sharp-libvips-*/**', - 'node_modules/@koromix/koffi-*/**', - 'node_modules/@vscode/ripgrep-*/**', - 'node_modules/node-addon-require-builtin-*/**', - 'node_modules/node-pty/prebuilds/**', - 'node_modules/pnpm/dist/vendor/**', + 'package.json', + 'cordis.patch.yml', + 'build/**', + 'lib/**', + 'node_modules/**', ]) expect(manifest.build?.electronFuses).toEqual({ runAsNode: true }) expect(manifest.build?.toolsets).toEqual({ nsis: '1.2.1' }) diff --git a/dsh-plugin-desktop/tests/profile.spec.ts b/dsh-plugin-desktop/tests/profile.spec.ts index 9f5724c516..26c4d263ed 100644 --- a/dsh-plugin-desktop/tests/profile.spec.ts +++ b/dsh-plugin-desktop/tests/profile.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -149,14 +149,6 @@ describe('desktop profile composition', { expect(repaired.custom.preserved).toBe(true) }) - it('does not materialize the installation dependency closure in the profiles directory', () => { - const home = temporaryHome() - - prepareDesktopProfile(undefined, home, 'win32') - - expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false) - }) - it('migrates the obsolete Desktop bundle before loading a historical profile', () => { const home = temporaryHome() const dir = ensureDesktopProfile(home) diff --git a/dsh-plugin-desktop/tests/verify-packaged-runtime.spec.ts b/dsh-plugin-desktop/tests/verify-packaged-runtime.spec.ts index 037b5b35fb..584803de53 100644 --- a/dsh-plugin-desktop/tests/verify-packaged-runtime.spec.ts +++ b/dsh-plugin-desktop/tests/verify-packaged-runtime.spec.ts @@ -4,22 +4,22 @@ import { describe, expect, it, vi } from 'vitest' import AdmZip from 'adm-zip' import { afterPack, - allowedUnpackedRuntimeEntry, FORBIDDEN_WINDOWS_X64_RUNTIME_PREFIXES, - REQUIRED_MACOS_UNIVERSAL_ENTRIES, REQUIRED_PACKAGED_RUNTIME_ENTRIES, + REQUIRED_MACOS_UNIVERSAL_ENTRIES, + REQUIRED_UNPACKED_PACKAGE_SPECIFIERS, REQUIRED_UNPACKED_RUNTIME_ENTRIES, - REQUIRED_WINDOWS_X64_NATIVE_ENTRIES, REQUIRED_WINDOWS_X64_NODE_PTY_ENTRIES, resolvePackagedAsarPath, resolvePackagedUnpackedRoot, smokePackagedDiagnosticWorker, + verifyUnpackedArchiveMirror, verifyPackagedRuntime, - verifyUnpackedRuntimeScope, type ArchiveLister, type FileProbe, - type PackagedDiagnosticWorkerLauncher, + type PackageResolver, type PackagedRuntimeContext, + type PackagedDiagnosticWorkerLauncher, } from '../scripts/verify-packaged-runtime.ts' import { FORBIDDEN_MACOS_UNIVERSAL_ENTRIES } from '../scripts/mac-universal.ts' @@ -40,33 +40,8 @@ function completeArchiveEntries(separator = '/'): string[] { return REQUIRED_PACKAGED_RUNTIME_ENTRIES.map(entry => `${separator}${entry.replaceAll('/', separator)}`) } -function requiredPhysicalEntries(runtimeContext: PackagedRuntimeContext): readonly string[] { - if (runtimeContext.electronPlatformName === 'win32') { - return [ - ...REQUIRED_UNPACKED_RUNTIME_ENTRIES, - ...REQUIRED_WINDOWS_X64_NODE_PTY_ENTRIES, - ...REQUIRED_WINDOWS_X64_NATIVE_ENTRIES, - ] - } - if (runtimeContext.electronPlatformName === 'darwin' && runtimeContext.arch === 4) { - return [ - ...REQUIRED_UNPACKED_RUNTIME_ENTRIES, - ...REQUIRED_MACOS_UNIVERSAL_ENTRIES, - ] - } - return REQUIRED_UNPACKED_RUNTIME_ENTRIES -} - -function physicalProbe( - runtimeContext: PackagedRuntimeContext, - options: { readonly missing?: string, readonly extra?: string } = {}, -): FileProbe { - const unpackedRoot = resolvePackagedUnpackedRoot(runtimeContext) - const present = new Set(requiredPhysicalEntries(runtimeContext) - .filter(entry => entry !== options.missing) - .map(entry => join(unpackedRoot, entry))) - if (options.extra !== undefined) present.add(join(unpackedRoot, options.extra)) - return filename => present.has(filename) +function completePackageResolver(unpackedRoot: string): PackageResolver { + return specifier => join(unpackedRoot, 'resolved', `${specifier.replaceAll('/', '-')}.js`) } describe('packaged desktop runtime verification', () => { @@ -96,7 +71,10 @@ describe('packaged desktop runtime verification', () => { expect(workerPath).toBe(join(unpackedRoot, 'lib', 'diagnostic-export-worker.js')) expect(readFileSync(join(workerData.logsDir, 'dsh-2000-01-01.log'), 'utf8')) .toBe('packaged worker smoke\n') + expect(workerData.appVersion).toBe('packaged-smoke') + expect(workerData.maxEvidenceBytes).toBe(1024) const crashDump = readFileSync(join(workerData.crashDumpsDir, 'pending', 'packaged-smoke.dmp')) + expect(crashDump.toString('utf8')).toBe('packaged crash dump smoke\n') const outDir = join(workerData.userDataDir, 'diagnostics') mkdirSync(outDir) const output = join(outDir, 'diagnostics-smoke.zip') @@ -127,6 +105,42 @@ describe('packaged desktop runtime verification', () => { expect(calls).toEqual(['static', resolvePackagedUnpackedRoot(runtimeContext)]) }) + it('tracks the ConPTY-only native surface shipped by node-pty 1.2', () => { + expect(REQUIRED_WINDOWS_X64_NODE_PTY_ENTRIES).toEqual([ + 'node_modules/node-pty/prebuilds/win32-x64/conpty.node', + 'node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node', + 'node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe', + 'node_modules/node-pty/prebuilds/win32-x64/conpty/conpty.dll', + ]) + }) + + it.each([ + 'lib/main.js.map', + 'node_modules/example/index.js.map', + 'node_modules/example/source.ts', + 'node_modules/example/source.mts', + 'node_modules/example/source.cts', + ])('rejects excluded development source %s', (forbidden) => { + expect(() => verifyPackagedRuntime( + context('/build', 'win32'), + () => [...completeArchiveEntries(), `/${forbidden}`], + () => true, + )).toThrow(`contains excluded development sources: ${forbidden}`) + }) + + it.each(FORBIDDEN_WINDOWS_X64_RUNTIME_PREFIXES)( + 'rejects forbidden Windows x64 runtime prefix %s', + (prefix) => { + const forbidden = `${prefix}payload.bin` + + expect(() => verifyPackagedRuntime( + context('/build', 'win32'), + () => [...completeArchiveEntries(), `/${forbidden}`], + () => true, + )).toThrow(`contains non-x64 or build-only entries: ${forbidden}`) + }, + ) + it.each([ [ 'darwin', @@ -137,14 +151,25 @@ describe('packaged desktop runtime verification', () => { join('/build', 'resources', 'app.asar'), ], ])('inspects the %s app.asar path', (platform, expectedPath) => { - const runtimeContext = context('/build', platform) const list = vi.fn(() => completeArchiveEntries(platform === 'win32' ? '\\' : '/')) - verifyPackagedRuntime(runtimeContext, list, physicalProbe(runtimeContext)) + const exists = vi.fn(() => true) + const unpackedRoot = `${expectedPath}.unpacked` + const resolvePackage = vi.fn(completePackageResolver(unpackedRoot)) - expect(resolvePackagedAsarPath(runtimeContext)).toBe(expectedPath) + verifyPackagedRuntime(context('/build', platform), list, exists, resolvePackage) + + expect(resolvePackagedAsarPath(context('/build', platform))).toBe(expectedPath) + expect(list).toHaveBeenCalledOnce() expect(list).toHaveBeenCalledWith(expectedPath, { isPack: false }) - expect(resolvePackagedUnpackedRoot(runtimeContext)).toBe(`${expectedPath}.unpacked`) + expect(resolvePackagedUnpackedRoot(context('/build', platform))).toBe(unpackedRoot) + expect(exists).toHaveBeenCalledTimes( + REQUIRED_UNPACKED_RUNTIME_ENTRIES.length + + (platform === 'win32' ? REQUIRED_WINDOWS_X64_NODE_PTY_ENTRIES.length : 0) + + completeArchiveEntries().length, + ) + expect(resolvePackage.mock.calls.map(([specifier]) => specifier)) + .toEqual(REQUIRED_UNPACKED_PACKAGE_SPECIFIERS) }) it('rejects an unsupported platform instead of guessing an archive layout', () => { @@ -152,80 +177,57 @@ describe('packaged desktop runtime verification', () => { .toThrow('unsupported Electron afterPack platform "mas"') }) - it.each([ - 'lib/main.js.map', - 'node_modules/example/index.js.map', - 'node_modules/example/source.ts', - 'node_modules/example/source.mts', - 'node_modules/example/source.cts', - ])('rejects excluded development source %s', (forbidden) => { - const runtimeContext = context('/build', 'win32') - expect(() => verifyPackagedRuntime( - runtimeContext, - () => [...completeArchiveEntries(), `/${forbidden}`], - physicalProbe(runtimeContext), - )).toThrow(`contains excluded development sources: ${forbidden}`) - }) - - it.each(FORBIDDEN_WINDOWS_X64_RUNTIME_PREFIXES)( - 'rejects forbidden Windows x64 runtime prefix %s', - (prefix) => { - const forbidden = `${prefix}payload.bin` - const runtimeContext = context('/build', 'win32') - - expect(() => verifyPackagedRuntime( - runtimeContext, - () => [...completeArchiveEntries(), `/${forbidden}`], - physicalProbe(runtimeContext), - )).toThrow(`contains non-x64 or build-only entries: ${forbidden}`) - }, - ) - - it('allows only native, executable, preset, and Worker paths outside ASAR', () => { - expect(allowedUnpackedRuntimeEntry('lib/diagnostic-export-worker.js')).toBe(true) - expect(allowedUnpackedRuntimeEntry('node_modules/@deepseek-ai/node-addon-landlock-run-linux-x64/native.node')).toBe(true) - expect(allowedUnpackedRuntimeEntry('node_modules/@vscode/ripgrep-win32-x64/bin/rg.exe')).toBe(true) - expect(allowedUnpackedRuntimeEntry('node_modules')).toBe(true) - expect(allowedUnpackedRuntimeEntry('lib/main.js')).toBe(false) - expect(allowedUnpackedRuntimeEntry('node_modules/yaml/dist/index.js')).toBe(false) - }) - - it('rejects ordinary JavaScript leaking into app.asar.unpacked', () => { - const unpackedRoot = join('/build', 'resources', 'app.asar.unpacked') - const leaked = 'node_modules/yaml/dist/index.js' - - expect(() => verifyUnpackedRuntimeScope( - new Set(['lib/main.js', leaked]), - unpackedRoot, - filename => filename === join(unpackedRoot, leaked), - )).toThrow(`contains non-whitelisted physical entries: ${leaked}`) - }) - it('requires both CPU variants from a universal macOS runtime', () => { const runtimeContext = context('/build', 'darwin', 4) + const unpackedRoot = resolvePackagedUnpackedRoot(runtimeContext) const missing = 'node_modules/@vscode/ripgrep-darwin-x64/bin/rg' expect(() => verifyPackagedRuntime( runtimeContext, () => completeArchiveEntries(), - physicalProbe(runtimeContext, { missing }), + filename => filename !== join(unpackedRoot, missing), + completePackageResolver(unpackedRoot), )).toThrow(`missing required physical entries: ${missing}`) + const exists = vi.fn(filename => !FORBIDDEN_MACOS_UNIVERSAL_ENTRIES + .some(entry => filename === join(unpackedRoot, entry))) verifyPackagedRuntime( runtimeContext, () => completeArchiveEntries(), - physicalProbe(runtimeContext), + exists, + completePackageResolver(unpackedRoot), ) + expect(exists).toHaveBeenCalledTimes( + REQUIRED_UNPACKED_RUNTIME_ENTRIES.length + + REQUIRED_MACOS_UNIVERSAL_ENTRIES.length + + FORBIDDEN_MACOS_UNIVERSAL_ENTRIES.length + + completeArchiveEntries().length, + ) + }) + + it('rejects any ASAR-declared unpacked dependency missing from the physical tree', () => { + const unpackedRoot = join('/build', 'resources', 'app.asar.unpacked') + const missing = 'node_modules/yaml/dist/index.js' + + expect(() => verifyUnpackedArchiveMirror( + new Set(['lib/main.js', missing, 'node_modules/zod/index.js']), + unpackedRoot, + filename => filename !== join(unpackedRoot, missing), + )).toThrow(`missing ASAR-declared physical entries: ${missing}`) }) it('rejects a host-architecture node-pty build from a universal app', () => { const runtimeContext = context('/build', 'darwin', 4) + const unpackedRoot = resolvePackagedUnpackedRoot(runtimeContext) const forbidden = FORBIDDEN_MACOS_UNIVERSAL_ENTRIES[0] expect(() => verifyPackagedRuntime( runtimeContext, () => completeArchiveEntries(), - physicalProbe(runtimeContext, { extra: forbidden }), + filename => filename === join(unpackedRoot, forbidden) + || !FORBIDDEN_MACOS_UNIVERSAL_ENTRIES + .some(entry => filename === join(unpackedRoot, entry)), + completePackageResolver(unpackedRoot), )).toThrow(`contains host-architecture build output: ${forbidden}`) }) @@ -239,29 +241,111 @@ describe('packaged desktop runtime verification', () => { 'lib/update-download.js', 'lib/windows-agent-presets.js', ])('fails loud when required runtime entry %s is absent', (missing) => { - const runtimeContext = context('/build', 'win32') const entries = completeArchiveEntries().filter(entry => entry !== `/${missing}`) - expect(() => verifyPackagedRuntime(runtimeContext, () => entries, physicalProbe(runtimeContext))) + expect(() => verifyPackagedRuntime(context('/build', 'win32'), () => entries, () => true)) .toThrow(`missing required ASAR entries: ${missing}`) }) it.each([ + 'package.json', + 'build/app-icon-mac.png', + 'build/tray-iconTemplate.png', + 'lib/terminal.js', + 'lib/diagnostics.js', 'lib/diagnostic-export-worker.js', - 'node_modules/@deepseek-ai/dsh/config/agent-presets/cordis/agent.cordis.yml', - 'node_modules/@deepseek-ai/dsh/config/agent-presets/cordis/skills/cordis-plugin-development/SKILL.md', - 'node_modules/@deepseek-ai/dsh/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md', - 'node_modules/@img/sharp-win32-x64/lib/sharp-win32-x64-0.35.3.node', - 'node_modules/@vscode/ripgrep-win32-x64/bin/rg.exe', + 'lib/update-download.js', + 'lib/windows-agent-presets.js', + 'node_modules/@deepseek-ai/dsh/lib/bin.js', + 'node_modules/pnpm/bin/pnpm.mjs', 'node_modules/node-pty/prebuilds/win32-x64/conpty.node', - 'node_modules/pnpm/dist/vendor/fastlist-0.3.0-x64.exe', - ])('fails loud when required physical runtime entry %s is absent', (missing) => { + ])('fails loud when physical runtime entry %s is absent from app.asar.unpacked', (missing) => { const runtimeContext = context('/build', 'win32') + const unpackedRoot = resolvePackagedUnpackedRoot(runtimeContext) + const missingPath = join(unpackedRoot, missing) expect(() => verifyPackagedRuntime( runtimeContext, () => completeArchiveEntries(), - physicalProbe(runtimeContext, { missing }), + filename => filename !== missingPath, + completePackageResolver(unpackedRoot), )).toThrow(`missing required physical entries: ${missing}`) }) + + it('requires the physical Cordis preset and its bundled skills', () => { + const runtimeContext = context('/build', 'win32') + const unpackedRoot = resolvePackagedUnpackedRoot(runtimeContext) + const requiredPresetEntries = [ + 'node_modules/@deepseek-ai/dsh/config/agent-presets/cordis/agent.cordis.yml', + 'node_modules/@deepseek-ai/dsh/config/agent-presets/cordis/skills/cordis-plugin-development/SKILL.md', + 'node_modules/@deepseek-ai/dsh/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md', + ] + + for (const missing of requiredPresetEntries) { + expect(() => verifyPackagedRuntime( + runtimeContext, + () => completeArchiveEntries(), + filename => filename !== join(unpackedRoot, missing), + completePackageResolver(unpackedRoot), + )).toThrow(`missing required physical entries: ${missing}`) + } + }) + + it('fails loud when a required package export cannot resolve from app.asar.unpacked', () => { + const runtimeContext = context('/build', 'win32') + const unpackedRoot = resolvePackagedUnpackedRoot(runtimeContext) + const resolvePackage = vi.fn((specifier) => { + if (specifier === 'dsh-plugin-desktop/profiles') { + throw new Error('missing export') + } + return completePackageResolver(unpackedRoot)(specifier) + }) + + expect(() => verifyPackagedRuntime( + runtimeContext, + () => completeArchiveEntries(), + () => true, + resolvePackage, + )).toThrow( + `packaged runtime at ${unpackedRoot} cannot resolve required package export dsh-plugin-desktop/profiles`, + ) + }) + + it('fails loud when schemastery is absent from app.asar.unpacked', () => { + const runtimeContext = context('/build', 'win32') + const unpackedRoot = resolvePackagedUnpackedRoot(runtimeContext) + const specifier = '@deepseek-ai/schemastery/package.json' + const resolvePackage = vi.fn((requested) => { + if (requested === specifier) throw new Error('missing package') + return completePackageResolver(unpackedRoot)(requested) + }) + + expect(() => verifyPackagedRuntime( + runtimeContext, + () => completeArchiveEntries(), + () => true, + resolvePackage, + )).toThrow( + `packaged runtime at ${unpackedRoot} cannot resolve required package export ${specifier}`, + ) + }) + + it('fails loud when a required package export escapes app.asar.unpacked', () => { + const runtimeContext = context('/build', 'win32') + const unpackedRoot = resolvePackagedUnpackedRoot(runtimeContext) + const escapedPath = join('/workspace', 'node_modules', '@deepseek-ai', 'dsh-base', 'lib', 'index.js') + const resolvePackage = vi.fn((specifier) => { + if (specifier === '@deepseek-ai/dsh-base/package.json') return escapedPath + return completePackageResolver(unpackedRoot)(specifier) + }) + + expect(() => verifyPackagedRuntime( + runtimeContext, + () => completeArchiveEntries(), + () => true, + resolvePackage, + )).toThrow( + `required package export @deepseek-ai/dsh-base/package.json resolved outside ${unpackedRoot}: ${escapedPath}`, + ) + }) }) diff --git a/dsh-plugin-desktop/tsdown.config.ts b/dsh-plugin-desktop/tsdown.config.ts index 9bbbe71720..63d9bb66a3 100644 --- a/dsh-plugin-desktop/tsdown.config.ts +++ b/dsh-plugin-desktop/tsdown.config.ts @@ -17,6 +17,7 @@ export default defineConfig([ profiles: 'src/profiles.ts', diagnostics: 'src/diagnostics.ts', notifications: 'src/notifications.ts', + 'diagnostic-export-worker': 'src/diagnostic-export-worker.ts', runtime: 'src/runtime.ts', 'electron-runtime': 'src/electron-runtime.ts', 'desktop-runtime-environment': 'src/desktop-runtime-environment.ts', @@ -40,22 +41,6 @@ export default defineConfig([ clean: false, sourcemap: true, }, - { - name: `${PACKAGE_NAME}/diagnostic-export-worker`, - entry: { 'diagnostic-export-worker': 'src/diagnostic-export-worker.ts' }, - outDir: 'lib', - format: 'esm', - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - sourcemap: true, - outputOptions: { - entryFileNames: 'diagnostic-export-worker.js', - codeSplitting: false, - }, - }, { name: `${PACKAGE_NAME}/bin`, entry: { bin: 'src/bin.ts' },