mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(pwsh): resolve Store app execution aliases
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md
|
||||
2026-08-12-resolve-store-pwsh-aliases.md: 20fe58e15e75462dc0a9ba76c7a1a94939f8a004
|
||||
2026-08-12-resolve-store-pwsh-aliases.zh.md: bbfa4616127a9dbdb6609fe2973283663de55b31
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Note: Resolve Microsoft Store pwsh aliases
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-12-resolve-store-pwsh-aliases.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`resolvePwshPath` documented that Microsoft Store installs resolve through PATH, but its existence probe was `existsSync`, which stats a candidate and therefore follows reparse points. The Store's `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe` is an app execution alias whose target directory ACL refuses stat (EACCES), so `existsSync` missed it and resolution silently fell through to Windows PowerShell 5.1 on hosts whose only PowerShell 7 is a Store install.
|
||||
|
||||
## Decision
|
||||
|
||||
`candidateExists` accepts a candidate that stats as a file or that lstat sees as a link-shaped reparse point, and `resolvePwshPath` uses it. Spawning the alias path works because CreateProcess resolves app execution aliases. A dangling link-shaped candidate is accepted so a broken pwsh fails loudly at spawn instead of silently downgrading to 5.1.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Probe the WindowsApps package directory directly.** The Store package path is versioned and ACL-hidden; hard-coding it duplicates packaging knowledge that PATH plus the alias already owns.
|
||||
|
||||
**Keep the 5.1 fallback for stat failures.** Rejected: it silently runs a different shell than the one installed, which is the defect this note fixes.
|
||||
|
||||
## Consequences
|
||||
|
||||
Store-installed PowerShell 7 now resolves ahead of the 5.1 fallback on Windows; real-file candidates and non-Windows behavior are unchanged. The dangling-symlink unit test pins the stat/lstat split on every platform.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Note: 解析 Microsoft Store 的 pwsh 别名
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-12-resolve-store-pwsh-aliases.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`resolvePwshPath` 声称 Store 安装经 PATH 解析,但它的存在性探测用的是 `existsSync`,会对候选做 stat、从而跟随重解析点。Store 的 `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe` 是 app execution alias,其目标目录的 ACL 拒绝 stat(EACCES),于是 `existsSync` 看不到它,解析静默落到 Windows PowerShell 5.1——在这类「唯一的 PowerShell 7 是 Store 安装」的机器上就用了错误的 shell。
|
||||
|
||||
## 决策
|
||||
|
||||
`candidateExists` 接受「stat 为文件」或「lstat 为链接形态重解析点」的候选,`resolvePwshPath` 改用它。spawn 别名路径可以工作,因为 CreateProcess 会解析 app execution alias。悬空的链接形态候选同样被接受,让损坏的 pwsh 在 spawn 时响亮失败,而不是静默降级到 5.1。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**直接探测 WindowsApps 包目录。** Store 包路径带版本且被 ACL 隐藏;硬编码它只是重复了 PATH 加别名已经拥有的打包知识。
|
||||
|
||||
**对 stat 失败继续走 5.1 回退。** 否决:它静默运行了一个并非所装的 shell,这正是本 note 修复的缺陷。
|
||||
|
||||
## 后果
|
||||
|
||||
Windows 上 Store 安装的 PowerShell 7 现在先于 5.1 回退被解析;普通文件候选和非 Windows 平台行为不变。悬空 symlink 单元测试在全部平台上钉住 stat/lstat 的分裂行为。
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user