fix(pwsh): resolve Store app execution aliases

This commit is contained in:
Huanqi Cao
2026-08-13 15:16:12 +08:00
committed by Chinesezjc
parent a62884a565
commit c29246ae31
5 changed files with 84 additions and 5 deletions
+15 -2
View File
@@ -8,7 +8,7 @@
* @module @deepseek-ai/dsh-pwsh-local/resolve
*/
import { existsSync } from 'node:fs'
import { existsSync, lstatSync } from 'node:fs'
import { join } from 'node:path'
/**
@@ -36,6 +36,19 @@ export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string
return candidates
}
/** Whether a candidate can be spawned: a real file or a link-shaped reparse point. */
function candidateExists(candidate: string): boolean {
if (existsSync(candidate)) return true
// Microsoft Store app execution aliases are reparse points whose target
// ACL refuses stat(), so existsSync misses them; lstat sees the link
// itself and CreateProcess resolves it when the executor spawns.
try {
return lstatSync(candidate).isSymbolicLink()
} catch {
return false
}
}
/**
* Resolve the pwsh executable this executor spawns.
* @param configured - an explicit `pwshPath` config value, trusted as-is.
@@ -53,7 +66,7 @@ export function resolvePwshPath(
if (configured !== undefined && configured.length > 0) return configured
if (platform === 'win32') {
for (const candidate of candidatePwshPaths(env)) {
if (existsSync(candidate)) return candidate
if (candidateExists(candidate)) return candidate
}
}
return 'pwsh'
@@ -9,7 +9,7 @@
* writes CRLF on Windows, so exact text assertions normalize line endings.
*/
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
@@ -126,6 +126,18 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: join(dir, 'empty'), SystemRoot: join(dir, 'no-windows') }, 'win32'))
.toBe('pwsh')
})
it('accepts a link-shaped PATH candidate whose target cannot be stat-ed', () => {
// Store app execution aliases stat as EACCES but lstat as a link; a
// dangling symlink reproduces that split on every platform.
const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-link-'))
const store = join(dir, 'store')
mkdirSync(store, { recursive: true })
const link = join(store, 'pwsh.exe')
symlinkSync(join(dir, 'no-such-target.exe'), link)
expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32'))
.toBe(link)
})
})
describe('spawn construction (pure, every platform)', () => {
@@ -298,8 +310,10 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
it('start returns immediately with a running handle that settles as completed', async () => {
const { bash } = await setup()
const before = Date.now()
const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 200; Write-Output done' }))
expect(Date.now() - before).toBeLessThan(150)
// The sleep outlasts any realistic spawn latency, so returning while the
// child still sleeps proves start() does not wait for completion.
const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 2000; Write-Output done' }))
expect(Date.now() - before).toBeLessThan(1000)
expect(proc.status).toBe('running')
await proc.done
expect(proc.status).toBe('completed')