diff --git a/build/profile-esm-resolver.mjs b/build/profile-esm-resolver.mjs new file mode 100644 index 00000000..9380cd8b --- /dev/null +++ b/build/profile-esm-resolver.mjs @@ -0,0 +1,55 @@ +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { isBareSpecifier, listProfileNodeModules } from './profile-node-modules.mjs' + +// The package was found in the profile but cannot be used from there. That is a +// better error than the generic "not found" the original importer produced, so +// it is reported instead of being swallowed by the next candidate. +const PROFILE_PACKAGE_ERRORS = new Set([ + 'ERR_PACKAGE_PATH_NOT_EXPORTED', + 'ERR_PACKAGE_IMPORT_NOT_DEFINED', + 'ERR_INVALID_PACKAGE_CONFIG', + 'ERR_INVALID_PACKAGE_TARGET', + 'ERR_UNSUPPORTED_DIR_IMPORT' +]) + +let dshHome +let profiles = [] + +export function initialize(data) { + dshHome = data?.dshHome + profiles = data?.profiles ?? [] +} + +// Two levels below node_modules, so that Node's own lookup walks up into +// /node_modules/ and applies package.json exports, +// conditions and subpaths exactly as it would for a local dependency. +function anchorFor(nodeModulesDirectory) { + return pathToFileURL(join(nodeModulesDirectory, '.dsh-desktop-anchor', 'anchor.js')).href +} + +export async function resolve(specifier, context, nextResolve) { + const parentURL = context.parentURL + + try { + // Default resolution first: the app bundle keeps ownership of its own + // dependency tree, and profiles only fill in what it cannot provide. + return await nextResolve(specifier, context) + } catch (error) { + if (!isBareSpecifier(specifier)) throw error + + const directories = listProfileNodeModules(dshHome, profiles) + for (const directory of directories) { + try { + return await nextResolve(specifier, { ...context, parentURL: anchorFor(directory) }) + } catch (profileError) { + if (PROFILE_PACKAGE_ERRORS.has(profileError?.code)) throw profileError + } + } + + // nextResolve merges the context it is handed into the shared context + // object, so the synthetic parent has to be undone before giving up. + context.parentURL = parentURL + throw error + } +} diff --git a/build/profile-module-paths.mjs b/build/profile-module-paths.mjs new file mode 100644 index 00000000..556cc947 --- /dev/null +++ b/build/profile-module-paths.mjs @@ -0,0 +1,65 @@ +import { createRequire, register } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { + isBareSpecifier, + listProfileNodeModules, + parseProfileNames +} from './profile-node-modules.mjs' + +// Plugins installed from the market live in DSH_HOME/profiles//node_modules, +// while the harness and its plugin loader run from the packaged app bundle. After +// packaging those are different trees, so a bare import of an installed plugin has +// to be able to fall back to the profile. +const dshHome = process.env.DSH_HOME +const profiles = parseProfileNames(process.env.DSH_DESKTOP_PROFILES) + +if (dshHome) { + installCommonJsFallback() + installEsmFallback() +} + +function installCommonJsFallback() { + try { + const require = createRequire(import.meta.url) + const Module = require('node:module') + const originalResolveFilename = Module._resolveFilename + + Module._resolveFilename = function (request, parent, isMain, options) { + try { + return originalResolveFilename.call(this, request, parent, isMain, options) + } catch (error) { + if (!isBareSpecifier(request)) throw error + for (const directory of listProfileNodeModules(dshHome, profiles)) { + try { + return originalResolveFilename.call(this, request, parent, isMain, { + ...options, + paths: [directory] + }) + } catch { + // this profile does not provide the package + } + } + throw error + } + } + } catch (error) { + warn('CommonJS', error) + } +} + +function installEsmFallback() { + try { + const resolver = join(dirname(fileURLToPath(import.meta.url)), 'profile-esm-resolver.mjs') + register(pathToFileURL(resolver).href, { data: { dshHome, profiles } }) + } catch (error) { + warn('ESM', error) + } +} + +function warn(loader, error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write( + `[dsh-desktop] profile plugin resolution is unavailable for ${loader} imports: ${message}\n` + ) +} diff --git a/build/profile-node-modules.d.mts b/build/profile-node-modules.d.mts new file mode 100644 index 00000000..e80a6886 --- /dev/null +++ b/build/profile-node-modules.d.mts @@ -0,0 +1,3 @@ +export declare function isBareSpecifier(specifier: unknown): boolean +export declare function parseProfileNames(value: string | undefined): string[] +export declare function listProfileNodeModules(home: string | undefined, profiles?: string[]): string[] diff --git a/build/profile-node-modules.mjs b/build/profile-node-modules.mjs new file mode 100644 index 00000000..86894eec --- /dev/null +++ b/build/profile-node-modules.mjs @@ -0,0 +1,53 @@ +import { existsSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +// A specifier is "bare" when Node has to look it up in a node_modules directory. +// Relative paths, absolute paths, package imports (`#name`) and anything with a +// URL scheme — including Windows drive letters such as `C:\` — resolve against +// the importer and must never be redirected to a profile. +export function isBareSpecifier(specifier) { + if (typeof specifier !== 'string' || specifier.length === 0) return false + if ( + specifier.startsWith('.') || + specifier.startsWith('/') || + specifier.startsWith('\\') || + specifier.startsWith('#') + ) { + return false + } + return !/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(specifier) +} + +export function parseProfileNames(value) { + if (!value) return [] + return value + .split(',') + .map((name) => name.trim()) + .filter((name) => name.length > 0 && !name.includes('/') && !name.includes('\\')) +} + +// Read on every lookup instead of snapshotting at startup: a profile only gains +// a node_modules directory when its first plugin is installed, which happens +// long after the harness process starts. +export function listProfileNodeModules(home, profiles = []) { + if (!home) return [] + const profilesDir = join(home, 'profiles') + const names = profiles.length > 0 ? profiles : readProfileNames(profilesDir) + const directories = [] + for (const name of names) { + const directory = join(profilesDir, name, 'node_modules') + if (existsSync(directory)) directories.push(directory) + } + return directories +} + +function readProfileNames(profilesDir) { + try { + return readdirSync(profilesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + } catch { + return [] + } +} diff --git a/package.json b/package.json index 07c2c546..08eb23e1 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,18 @@ "from": "build/harness-node-entry.mjs", "to": "harness-node-entry.mjs" }, + { + "from": "build/profile-module-paths.mjs", + "to": "profile-module-paths.mjs" + }, + { + "from": "build/profile-esm-resolver.mjs", + "to": "profile-esm-resolver.mjs" + }, + { + "from": "build/profile-node-modules.mjs", + "to": "profile-node-modules.mjs" + }, { "from": "build/dsh-desktop.patch.yml", "to": "dsh-desktop.patch.yml" diff --git a/src/main/index.ts b/src/main/index.ts index d118507e..43be14d1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -457,6 +457,7 @@ async function bootstrap(): Promise { dshEntryPath: dshEntryPath(), nodeExecutablePath: bundledNodePath(), nodeEntryPath: harnessNodeEntryPath(), + profileModulePathsPath: desktopResourcePath('profile-module-paths.mjs'), dshPatchPath: desktopResourcePath('dsh-desktop.patch.yml'), dshHome: join(app.getPath('userData'), 'harness'), logPath: join(app.getPath('logs'), 'harness.log'), diff --git a/src/main/runtime/harness-runtime.ts b/src/main/runtime/harness-runtime.ts index 7555bdbb..0347e256 100644 --- a/src/main/runtime/harness-runtime.ts +++ b/src/main/runtime/harness-runtime.ts @@ -3,12 +3,14 @@ import { createWriteStream, existsSync, type WriteStream } from 'node:fs' import { mkdir } from 'node:fs/promises' import { createServer } from 'node:net' import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' import type { RuntimePhase, RuntimeSnapshot } from '../../shared/contracts' export interface HarnessRuntimeOptions { dshEntryPath: string nodeExecutablePath: string nodeEntryPath: string + profileModulePathsPath: string dshPatchPath: string dshHome: string logPath: string @@ -21,9 +23,13 @@ export interface HarnessRuntimeOptions { onChanged(snapshot: RuntimeSnapshot): void } +// The desktop app always runs the `web` profile, which is also where the plugin +// market installs packages (DSH_HOME/profiles/web/node_modules). +export const HARNESS_PROFILE = 'web' + export function buildHarnessArguments(port: number, patchPath?: string): string[] { return [ - 'web', + HARNESS_PROFILE, ...(patchPath ? ['--patch', patchPath] : []), '--host', '127.0.0.1', @@ -47,6 +53,7 @@ export function buildHarnessSpawnOptions( ...parentEnvironment, DSH_HOME: dshHome, NO_COLOR: '1', + DSH_DESKTOP_PROFILES: HARNESS_PROFILE, [pathKey]: environment[pathKey] ?? environment.PATH ?? '' }, stdio: ['pipe', 'pipe', 'pipe'], @@ -58,10 +65,12 @@ export function buildNodeArguments( nodeEntryPath: string, dshEntryPath: string, port: number, - patchPath?: string + patchPath?: string, + profileModulePathsUrl?: string ): string[] { return [ '--expose-internals', + ...(profileModulePathsUrl ? ['--import', profileModulePathsUrl] : []), nodeEntryPath, dshEntryPath, ...buildHarnessArguments(port, patchPath) @@ -117,17 +126,28 @@ export class HarnessRuntime { const port = await reservePort() const url = `http://127.0.0.1:${port}` + // Resolving profile plugins is best effort: a missing shim must not turn + // into an unexplained `--import` crash before the harness entry even runs. + const profileModulePathsUrl = existsSync(this.options.profileModulePathsPath) + ? pathToFileURL(this.options.profileModulePathsPath).href + : undefined const args = buildNodeArguments( this.options.nodeEntryPath, this.options.dshEntryPath, port, - this.options.dshPatchPath + this.options.dshPatchPath, + profileModulePathsUrl ) const startupTimeoutMs = this.options.startupTimeoutMs ?? (process.platform === 'win32' ? 120_000 : 45_000) this.writeLog(`\n[desktop] starting ${new Date().toISOString()}`) this.writeLog(`[desktop] launch directory ${launchDirectory}`) + if (!profileModulePathsUrl) { + this.writeLog( + `[desktop] profile plugin resolution is disabled: ${this.options.profileModulePathsPath} was not found` + ) + } this.writeLog(`[desktop] endpoint ${url}`) this.setState('starting', 'Starting DeepSeek Harness…') diff --git a/test/profile-module-paths.test.ts b/test/profile-module-paths.test.ts new file mode 100644 index 00000000..4b3de3e5 --- /dev/null +++ b/test/profile-module-paths.test.ts @@ -0,0 +1,297 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { isBareSpecifier, listProfileNodeModules, parseProfileNames } from '../build/profile-node-modules.mjs' + +const execFileAsync = promisify(execFile) +const setupUrl = pathToFileURL(join(process.cwd(), 'build', 'profile-module-paths.mjs')).href + +const workspaces: string[] = [] + +afterEach(async () => { + await Promise.all(workspaces.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +async function createWorkspace(): Promise<{ home: string; app: string }> { + const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-resolution-')) + workspaces.push(root) + const app = join(root, 'app') + await mkdir(app, { recursive: true }) + return { home: join(root, 'home'), app } +} + +async function writePackage( + nodeModules: string, + name: string, + marker: string, + options: { module?: boolean } = {} +): Promise { + const directory = join(nodeModules, ...name.split('/')) + await mkdir(directory, { recursive: true }) + const module = options.module ?? true + await writeFile( + join(directory, 'package.json'), + JSON.stringify({ + name, + version: '1.0.0', + main: 'index.js', + ...(module ? { type: 'module' } : {}) + }) + ) + await writeFile( + join(directory, 'index.js'), + module ? `export const marker = ${JSON.stringify(marker)}\n` : `module.exports = ${JSON.stringify(marker)}\n` + ) + return directory +} + +/** Runs `script` the way the desktop app launches the harness: with the shim preloaded. */ +async function runWithShim( + app: string, + home: string, + script: string, + environment: NodeJS.ProcessEnv = {} +): Promise { + const entry = join(app, `entry-${Math.random().toString(36).slice(2)}.mjs`) + await writeFile(entry, script) + const { stdout } = await execFileAsync(process.execPath, ['--import', setupUrl, entry], { + cwd: app, + env: { ...process.env, DSH_HOME: home, DSH_DESKTOP_PROFILES: 'web', ...environment } + }) + return stdout.trim() +} + +describe('profile plugin resolution', () => { + it('resolves a plugin that only exists in the profile', async () => { + const { home, app } = await createWorkspace() + await writePackage(join(home, 'profiles', 'web', 'node_modules'), '@liustack/modlens', 'profile') + + const output = await runWithShim( + app, + home, + "const { marker } = await import('@liustack/modlens')\nprocess.stdout.write(marker)\n" + ) + + expect(output).toBe('profile') + }, 20_000) + + it('keeps resolving app bundle packages once a profile exists', async () => { + const { home, app } = await createWorkspace() + await writePackage(join(home, 'profiles', 'web', 'node_modules'), 'installed-plugin', 'profile') + await writePackage(join(app, 'node_modules'), 'bundled-only', 'bundle') + + const output = await runWithShim( + app, + home, + "const { marker } = await import('bundled-only')\nprocess.stdout.write(marker)\n" + ) + + expect(output).toBe('bundle') + }, 20_000) + + it('never lets a profile shadow the app bundle dependency tree', async () => { + const { home, app } = await createWorkspace() + await writePackage(join(home, 'profiles', 'web', 'node_modules'), 'shared', 'profile') + const bundled = await writePackage(join(app, 'node_modules'), 'bundled', 'bundle') + await writePackage(join(bundled, 'node_modules'), 'shared', 'bundle-nested') + await writeFile(join(bundled, 'index.js'), "export { marker } from 'shared'\n") + + const output = await runWithShim( + app, + home, + "const { marker } = await import('bundled')\nprocess.stdout.write(marker)\n" + ) + + expect(output).toBe('bundle-nested') + }, 20_000) + + it('picks up a profile node_modules created after the harness started', async () => { + const { home, app } = await createWorkspace() + const profileNodeModules = join(home, 'profiles', 'web', 'node_modules') + await mkdir(home, { recursive: true }) + + const output = await runWithShim( + app, + home, + [ + "import { mkdir, writeFile } from 'node:fs/promises'", + `const nodeModules = ${JSON.stringify(profileNodeModules)}`, + "const directory = nodeModules + '/late-plugin'", + 'await mkdir(directory, { recursive: true })', + `await writeFile(directory + '/package.json', ${JSON.stringify( + JSON.stringify({ name: 'late-plugin', version: '1.0.0', type: 'module', main: 'index.js' }) + )})`, + "await writeFile(directory + '/index.js', 'export const marker = \\'late\\'\\n')", + "const { marker } = await import('late-plugin')", + 'process.stdout.write(marker)' + ].join('\n') + ) + + expect(output).toBe('late') + }, 20_000) + + // pnpm is what the market installer runs, so a profile's top level is a set of + // symlinks into .pnpm and a plugin's own dependencies live beside its real path. + it('resolves a plugin through the pnpm store layout', async () => { + const { home, app } = await createWorkspace() + const nodeModules = join(home, 'profiles', 'web', 'node_modules') + const store = join(nodeModules, '.pnpm') + + const plugin = await writePackage(join(store, 'plugin@1.0.0', 'node_modules'), 'plugin', 'unused') + await writeFile(join(plugin, 'package.json'), JSON.stringify({ + name: 'plugin', + version: '1.0.0', + type: 'module', + exports: { '.': './index.js' } + })) + await writeFile(join(plugin, 'index.js'), "export { marker } from 'dependency'\n") + const dependency = await writePackage( + join(store, 'dependency@1.0.0', 'node_modules'), + 'dependency', + 'pnpm-store' + ) + + await symlink(plugin, join(nodeModules, 'plugin'), 'junction') + await symlink(dependency, join(store, 'plugin@1.0.0', 'node_modules', 'dependency'), 'junction') + + const output = await runWithShim( + app, + home, + "const { marker } = await import('plugin')\nprocess.stdout.write(marker)\n" + ) + + expect(output).toBe('pnpm-store') + }, 20_000) + + it('only consults the profiles the harness actually runs', async () => { + const { home, app } = await createWorkspace() + await writePackage(join(home, 'profiles', 'other', 'node_modules'), 'other-plugin', 'other') + + const output = await runWithShim( + app, + home, + [ + "let outcome = 'resolved'", + "try { await import('other-plugin') } catch (error) { outcome = error.code }", + 'process.stdout.write(outcome)' + ].join('\n') + ) + + expect(output).toBe('ERR_MODULE_NOT_FOUND') + }, 20_000) + + it('reports the original error when no profile provides the package', async () => { + const { home, app } = await createWorkspace() + await writePackage(join(home, 'profiles', 'web', 'node_modules'), 'installed-plugin', 'profile') + + const output = await runWithShim( + app, + home, + [ + "let message = 'resolved'", + "try { await import('missing-everywhere') } catch (error) { message = error.message }", + 'process.stdout.write(message)' + ].join('\n') + ) + + expect(output).toContain('missing-everywhere') + expect(output).not.toContain('anchor') + }, 20_000) + + it('surfaces a broken profile package instead of a generic not-found', async () => { + const { home, app } = await createWorkspace() + const plugin = await writePackage(join(home, 'profiles', 'web', 'node_modules'), 'strict-plugin', 'profile') + await writeFile( + join(plugin, 'package.json'), + JSON.stringify({ name: 'strict-plugin', version: '1.0.0', type: 'module', exports: { '.': './index.js' } }) + ) + + const output = await runWithShim( + app, + home, + [ + "let code = 'resolved'", + "try { await import('strict-plugin/hidden') } catch (error) { code = error.code }", + 'process.stdout.write(code)' + ].join('\n') + ) + + expect(output).toBe('ERR_PACKAGE_PATH_NOT_EXPORTED') + }, 20_000) + + it('applies the same fallback to CommonJS requires', async () => { + const { home, app } = await createWorkspace() + const profileNodeModules = join(home, 'profiles', 'web', 'node_modules') + await writePackage(profileNodeModules, 'cjs-plugin', 'profile', { module: false }) + await writePackage(profileNodeModules, 'cjs-shared', 'profile', { module: false }) + await writePackage(join(app, 'node_modules'), 'cjs-shared', 'bundle', { module: false }) + await writePackage(join(app, 'node_modules'), 'cjs-bundled', 'bundle', { module: false }) + + const output = await runWithShim( + app, + home, + [ + "import { createRequire } from 'node:module'", + 'const require = createRequire(import.meta.url)', + "process.stdout.write([require('cjs-plugin'), require('cjs-shared'), require('cjs-bundled')].join(','))" + ].join('\n') + ) + + expect(output).toBe('profile,bundle,bundle') + }, 20_000) + + it('leaves explicit require.resolve paths alone', async () => { + const { home, app } = await createWorkspace() + await writePackage(join(home, 'profiles', 'web', 'node_modules'), 'scoped', 'profile', { module: false }) + const bundled = await writePackage(join(app, 'node_modules'), 'bundled', 'bundle', { module: false }) + await writePackage(join(bundled, 'node_modules'), 'scoped', 'bundle-nested', { module: false }) + + const output = await runWithShim( + app, + home, + [ + "import { createRequire } from 'node:module'", + 'const require = createRequire(import.meta.url)', + `const resolved = require.resolve('scoped', { paths: [${JSON.stringify(bundled)}] })`, + 'process.stdout.write(require(resolved))' + ].join('\n') + ) + + expect(output).toBe('bundle-nested') + }, 20_000) +}) + +describe('profile discovery', () => { + it('treats only node_modules lookups as redirectable', () => { + expect(isBareSpecifier('@liustack/modlens')).toBe(true) + expect(isBareSpecifier('cordis')).toBe(true) + expect(isBareSpecifier('./relative.js')).toBe(false) + expect(isBareSpecifier('/absolute/path.js')).toBe(false) + expect(isBareSpecifier('#internal')).toBe(false) + expect(isBareSpecifier('node:path')).toBe(false) + expect(isBareSpecifier('file:///app/module.js')).toBe(false) + expect(isBareSpecifier('data:text/javascript,export default 1')).toBe(false) + expect(isBareSpecifier('C:\\app\\module.js')).toBe(false) + }) + + it('ignores profile names that could escape the profiles directory', () => { + expect(parseProfileNames('web, staging ')).toEqual(['web', 'staging']) + expect(parseProfileNames('../../etc,web')).toEqual(['web']) + expect(parseProfileNames(undefined)).toEqual([]) + }) + + it('skips profiles that have no node_modules yet', async () => { + const { home } = await createWorkspace() + await mkdir(join(home, 'profiles', 'empty'), { recursive: true }) + const populated = join(home, 'profiles', 'web', 'node_modules') + await mkdir(populated, { recursive: true }) + + expect(listProfileNodeModules(home)).toEqual([populated]) + expect(listProfileNodeModules(home, ['empty'])).toEqual([]) + expect(listProfileNodeModules(undefined)).toEqual([]) + }) +}) diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 816548ab..9bbf1c1f 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -1,9 +1,16 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' +import { EventEmitter } from 'node:events' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from 'node:child_process' import { buildHarnessArguments, buildHarnessSpawnOptions, buildNodeArguments, - formatExitCode + formatExitCode, + HarnessRuntime, + HARNESS_PROFILE } from '../src/main/runtime/harness-runtime' import { canGrantWindowPermission, isTrustedAppUrl } from '../src/main/security-policy' import { @@ -56,6 +63,7 @@ describe('Harness launch contract', () => { Path: 'windows-path' } }) + expect(options.env).toMatchObject({ DSH_DESKTOP_PROFILES: HARNESS_PROFILE }) expect(options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE') }) @@ -81,6 +89,33 @@ describe('Harness launch contract', () => { ]) }) + // `--import` takes a module specifier: a bare Windows path is read as a `c:` + // URL scheme and Node exits before the harness entry ever runs. + it('injects profile module paths via --import as a file URL', () => { + expect( + buildNodeArguments( + 'C:\\app\\harness-node-entry.mjs', + 'C:\\app\\dsh\\lib\\bin.js', + 43127, + 'C:\\app\\dsh-desktop.patch.yml', + 'file:///C:/app/profile-module-paths.mjs' + ) + ).toEqual([ + '--expose-internals', + '--import', + 'file:///C:/app/profile-module-paths.mjs', + 'C:\\app\\harness-node-entry.mjs', + 'C:\\app\\dsh\\lib\\bin.js', + 'web', + '--patch', + 'C:\\app\\dsh-desktop.patch.yml', + '--host', + '127.0.0.1', + '--port', + '43127' + ]) + }) + it('makes native Windows termination codes diagnosable', () => { expect(formatExitCode(4294930435)).toContain( '0xFFFF7003, Crashpad handler unavailable' @@ -88,6 +123,71 @@ describe('Harness launch contract', () => { }) }) +describe('Harness launch wiring', () => { + const roots: string[] = [] + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) + }) + + async function launchOnce( + options: { withProfileModulePaths: boolean } + ): Promise<{ args: string[]; environment: NodeJS.ProcessEnv }> { + const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-runtime-')) + roots.push(root) + + const paths: Record = {} + for (const name of ['bin.js', 'node', 'harness-node-entry.mjs', 'dsh-desktop.patch.yml']) { + paths[name] = join(root, name) + await writeFile(paths[name]!, '') + } + const profileModulePathsPath = join(root, 'profile-module-paths.mjs') + if (options.withProfileModulePaths) await writeFile(profileModulePathsPath, '') + + let captured: { args: string[]; environment: NodeJS.ProcessEnv } | undefined + const runtime = new HarnessRuntime({ + dshEntryPath: paths['bin.js']!, + nodeExecutablePath: paths['node']!, + nodeEntryPath: paths['harness-node-entry.mjs']!, + profileModulePathsPath, + dshPatchPath: paths['dsh-desktop.patch.yml']!, + dshHome: join(root, 'harness'), + logPath: join(root, 'logs', 'harness.log'), + startupTimeoutMs: 1, + onChanged: () => {}, + launchProcess: (_executable, args, spawnOptions: SpawnOptionsWithoutStdio) => { + captured = { args, environment: spawnOptions.env ?? {} } + const child = new EventEmitter() as unknown as ChildProcessWithoutNullStreams + Object.assign(child, { stdout: new EventEmitter(), stderr: new EventEmitter(), exitCode: 0 }) + return child + } + }) + + await runtime.start(join(root, 'launch')) + await runtime.stop() + expect(captured).toBeDefined() + return captured! + } + + // A bare Windows path is read as a `c:` URL scheme and Node exits before the + // harness entry runs, so the preload has to be handed over as a file URL. + it('preloads the profile resolution shim as a file URL', async () => { + const { args, environment } = await launchOnce({ withProfileModulePaths: true }) + + const specifier = args[args.indexOf('--import') + 1] + expect(specifier).toMatch(/^file:\/\//) + expect(new URL(specifier!).pathname).toContain('profile-module-paths.mjs') + expect(environment.DSH_DESKTOP_PROFILES).toBe(HARNESS_PROFILE) + }) + + it('starts without the shim rather than crashing when it is missing', async () => { + const { args } = await launchOnce({ withProfileModulePaths: false }) + + expect(args).not.toContain('--import') + expect(args[0]).toBe('--expose-internals') + }) +}) + describe('navigation trust boundary', () => { it('only trusts the launcher and loopback HTTP pages', () => { expect(isTrustedAppUrl('file:///app/index.html')).toBe(true)