Files
workdsh/dsh-plugin-desktop/scripts/verify-packaged-runtime.ts
T

427 lines
16 KiB
TypeScript

/** Fail-loud verification of the runtime entries sealed into Electron's app.asar. */
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, isAbsolute, join, relative, sep } from 'node:path'
import { Worker } from 'node:worker_threads'
import { listPackage } from '@electron/asar'
import AdmZip from 'adm-zip'
import {
FORBIDDEN_MACOS_UNIVERSAL_ENTRIES,
MACOS_UNIVERSAL_NATIVE_ENTRIES,
} from './mac-universal.ts'
const DSH_PACKAGE_ROOT = dirname(createRequire(import.meta.url).resolve('@deepseek-ai/dsh/package.json'))
/** Every generated JavaScript file shipped by the installed DSH CLI package. */
export const REQUIRED_DSH_CLI_RUNTIME_ENTRIES = Object.freeze(
readdirSync(join(DSH_PACKAGE_ROOT, 'lib'), { withFileTypes: true })
.filter(entry => entry.isFile() && entry.name.endsWith('.js'))
.map(entry => `node_modules/@deepseek-ai/dsh/lib/${entry.name}`)
.sort(),
)
/** AfterPack fields consumed without importing Electron Builder's incomplete declaration graph. */
export interface PackagedRuntimeContext {
/** Completed platform application directory. */
readonly appOutDir: string
/** Electron Builder target architecture (`4` is its stable universal enum value). */
readonly arch?: number
/** Electron target platform selected by the packager. */
readonly electronPlatformName: string
/** Product metadata used to locate the macOS application bundle. */
readonly packager: {
readonly appInfo: {
readonly productFilename: string
}
}
}
/** Exact archive entries required by the desktop launcher on every supported platform. */
export const REQUIRED_PACKAGED_RUNTIME_ENTRIES = [
'package.json',
'lib/main.js',
'lib/client.js',
'lib/native-ui/profile-create.html',
'lib/native-ui/recovery.html',
'lib/native-ui/setup-wizard.html',
'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/desktop-cli.js',
'lib/desktop-runtime-environment.js',
'lib/desktop-terminal.js',
'lib/terminal.js',
'lib/update-checker.js',
'lib/update-download.js',
'lib/updates.js',
'lib/windows-acl-runner.js',
...REQUIRED_DSH_CLI_RUNTIME_ENTRIES,
'node_modules/@deepseek-ai/dsh-subprocess-local/lib/index.js',
'node_modules/@deepseek-ai/dsh-web-frontend/dist/index.html',
'node_modules/@deepseek-ai/dsh-app-boot/lib/index.js',
'node_modules/open/index.js',
'node_modules/pnpm/bin/pnpm.mjs',
] as const
/** 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/native-ui/setup-wizard.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-pwsh-sandbox.js',
'node_modules/@deepseek-ai/dsh/package.json',
'node_modules/@deepseek-ai/dsh-agent-presets/package.json',
'node_modules/@deepseek-ai/dsh-agent-presets/presets/cordis/agent.cordis.yml',
'node_modules/@deepseek-ai/dsh-agent-presets/presets/cordis/skills/cordis-plugin-development/SKILL.md',
'node_modules/@deepseek-ai/dsh-agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md',
...REQUIRED_DSH_CLI_RUNTIME_ENTRIES,
'node_modules/@deepseek-ai/dsh-subprocess-local/lib/index.js',
'node_modules/@deepseek-ai/dsh-app-boot/lib/index.js',
'node_modules/@deepseek-ai/dsh-web-frontend/dist/index.html',
'node_modules/open/index.js',
'node_modules/pnpm/bin/pnpm.mjs',
] as const
/** Prebuilt Node-API modules required when the Windows package skips native source rebuilds. */
export const REQUIRED_WINDOWS_X64_NODE_PTY_ENTRIES = [
'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',
] as const
/** CPU-specific runtime assets that must coexist in a universal macOS application. */
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-beta',
'dsh-plugin-desktop-beta/profile',
'dsh-plugin-desktop-beta/client',
'dsh-plugin-desktop-beta/terminal',
'dsh-plugin-desktop-beta/pnpm',
'dsh-plugin-desktop-beta/profile-service',
'dsh-plugin-desktop-beta/profiles',
'dsh-plugin-desktop-beta/diagnostics',
'dsh-plugin-desktop-beta/notifications',
'dsh-plugin-desktop-beta/updates',
'dsh-plugin-desktop-beta/windows-pwsh-sandbox',
'dsh-plugin-desktop-beta/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
readonly userDataDir: string
readonly appVersion: string
readonly maxEvidenceBytes: number
readonly crashDumpsDir: string
}
/** Injectable packaged Worker launcher used by focused tests. */
export type PackagedDiagnosticWorkerLauncher = (
workerPath: string,
workerData: PackagedDiagnosticWorkerData,
) => Promise<string>
/** Injectable smoke seam used to verify afterPack ordering. */
export type PackagedDiagnosticWorkerSmoke = (unpackedRoot: string) => Promise<void>
/** Result posted by the bundled diagnostics Worker. */
type PackagedDiagnosticWorkerResult =
| { readonly ok: true, readonly path: string }
| { readonly ok: false, readonly error: string }
const PACKAGED_DIAGNOSTIC_WORKER_TIMEOUT_MS = 30_000
/** Start the physical packaged diagnostics Worker and wait for its terminal result. */
async function launchPackagedDiagnosticWorker(
workerPath: string,
workerData: PackagedDiagnosticWorkerData,
): Promise<string> {
return await new Promise((resolve, reject) => {
const worker = new Worker(workerPath, {
name: 'dsh-packaged-diagnostic-smoke',
workerData,
resourceLimits: { maxOldGenerationSizeMb: 256 },
})
let settled = false
const timeout = setTimeout(() => {
if (settled) return
settled = true
void worker.terminate()
reject(new Error(
`dsh-plugin-desktop: packaged diagnostic worker timed out after ${String(PACKAGED_DIAGNOSTIC_WORKER_TIMEOUT_MS)}ms`,
))
}, PACKAGED_DIAGNOSTIC_WORKER_TIMEOUT_MS)
const settle = (complete: () => void): void => {
if (settled) return
settled = true
clearTimeout(timeout)
void worker.terminate()
complete()
}
worker.once('message', (result: PackagedDiagnosticWorkerResult) => {
if (result.ok) settle(() => resolve(result.path))
else settle(() => reject(new Error(result.error)))
})
worker.once('error', cause => settle(() => reject(cause)))
worker.once('exit', (code) => {
settle(() => reject(new Error(
`dsh-plugin-desktop: packaged diagnostic worker exited with code ${String(code)}`,
)))
})
})
}
/** Exercise the physical Worker emitted beside app.asar with a minimal archive. */
export async function smokePackagedDiagnosticWorker(
unpackedRoot: string,
launch: PackagedDiagnosticWorkerLauncher = launchPackagedDiagnosticWorker,
): Promise<void> {
const root = mkdtempSync(join(tmpdir(), 'dsh-packaged-diagnostics-'))
const logsDir = join(root, 'logs')
const userDataDir = join(root, 'user-data')
const crashDumpsDir = join(root, 'Crashpad')
mkdirSync(logsDir)
mkdirSync(userDataDir)
mkdirSync(join(crashDumpsDir, 'pending'), { recursive: true })
writeFileSync(join(logsDir, 'dsh-2000-01-01.log'), 'packaged worker smoke\n')
writeFileSync(join(crashDumpsDir, 'pending', 'packaged-smoke.dmp'), 'packaged crash dump smoke\n')
try {
const output = await launch(
join(unpackedRoot, 'lib', 'diagnostic-export-worker.js'),
{ logsDir, userDataDir, appVersion: 'packaged-smoke', maxEvidenceBytes: 1024, crashDumpsDir },
)
if (!existsSync(output)) {
throw new Error(`dsh-plugin-desktop: packaged diagnostic worker produced no archive at ${output}`)
}
const crashEntry = 'crash-dumps/pending/packaged-smoke.dmp'
if (new AdmZip(output).getEntry(crashEntry) === null) {
throw new Error(`dsh-plugin-desktop: packaged diagnostic worker omitted ${crashEntry}`)
}
} finally {
rmSync(root, { recursive: true, force: true })
}
}
/**
* Resolve the platform-specific archive produced by Electron Builder.
* @param context - completed application directory and target platform.
* @returns absolute path to the packaged app.asar.
*/
export function resolvePackagedAsarPath(context: PackagedRuntimeContext): string {
if (context.electronPlatformName === 'darwin') {
return join(
context.appOutDir,
`${context.packager.appInfo.productFilename}.app`,
'Contents',
'Resources',
'app.asar',
)
}
if (context.electronPlatformName === 'win32' || context.electronPlatformName === 'linux') {
return join(context.appOutDir, 'resources', 'app.asar')
}
throw new Error(
`dsh-plugin-desktop: unsupported Electron afterPack platform ${JSON.stringify(context.electronPlatformName)}`,
)
}
/**
* Resolve the physical dependency tree emitted beside app.asar.
* @param context - completed application directory and target platform.
* @returns absolute path to app.asar.unpacked.
*/
export function resolvePackagedUnpackedRoot(context: PackagedRuntimeContext): string {
return `${resolvePackagedAsarPath(context)}.unpacked`
}
/** Normalize the host-specific separators emitted by the ASAR reader. */
function normalizeArchiveEntry(entry: string): string {
return entry.replaceAll('\\', '/').replace(/^\/+/, '').replace(/\/+$/, '')
}
/**
* Inspect one archive and reject an incomplete packaged runtime.
* @param archivePath - resolved app.asar path.
* @param list - ASAR listing implementation.
* @returns The normalized archive entry set for physical mirror verification.
*/
export function verifyPackagedAsar(
archivePath: string,
list: ArchiveLister = listPackage,
): ReadonlySet<string> {
let entries: readonly string[]
try {
entries = list(archivePath, { isPack: false })
} catch (cause) {
throw new Error(
`dsh-plugin-desktop: failed to inspect packaged runtime at ${archivePath}`,
{ cause },
)
}
const present = new Set(entries.map(normalizeArchiveEntry))
const missing = REQUIRED_PACKAGED_RUNTIME_ENTRIES.filter(entry => !present.has(entry))
if (missing.length > 0) {
throw new Error(
`dsh-plugin-desktop: packaged runtime at ${archivePath} is missing required ASAR entries: ${missing.join(', ')}`,
)
}
return present
}
/**
* 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 verifyUnpackedArchiveMirror(
archiveEntries: ReadonlySet<string>,
unpackedRoot: string,
exists: FileProbe = existsSync,
): void {
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} 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]
: context.electronPlatformName === 'darwin' && context.arch === 4
? [...REQUIRED_UNPACKED_RUNTIME_ENTRIES, ...REQUIRED_MACOS_UNIVERSAL_ENTRIES]
: REQUIRED_UNPACKED_RUNTIME_ENTRIES
const missing = requiredPhysicalEntries.filter(entry => !exists(join(unpackedRoot, entry)))
if (missing.length > 0) {
throw new Error(
`dsh-plugin-desktop: packaged runtime at ${unpackedRoot} is missing required physical entries: ${missing.join(', ')}`,
)
}
if (context.electronPlatformName === 'darwin' && context.arch === 4) {
const forbidden = FORBIDDEN_MACOS_UNIVERSAL_ENTRIES
.filter(entry => exists(join(unpackedRoot, entry)))
if (forbidden.length > 0) {
throw new Error(
`dsh-plugin-desktop: universal macOS runtime at ${unpackedRoot} contains host-architecture build output: ${forbidden.join(', ')}`,
)
}
}
verifyUnpackedArchiveMirror(archiveEntries, unpackedRoot, exists)
verifyUnpackedPackageResolution(unpackedRoot, resolvePackage)
}
/**
* Run the static packaged-runtime check as Electron Builder's afterPack hook.
* @param context - Electron Builder's afterPack context.
* @returns A promise that rejects before signing when the runtime is incomplete.
*/
export async function afterPack(
context: PackagedRuntimeContext,
verify: typeof verifyPackagedRuntime = verifyPackagedRuntime,
smoke: PackagedDiagnosticWorkerSmoke = smokePackagedDiagnosticWorker,
): Promise<void> {
verify(context)
await smoke(resolvePackagedUnpackedRoot(context))
}
export default afterPack