fix(pwsh-local): pin UTF-8 I/O so the Windows PowerShell 5.1 fallback cannot garble output
This commit is contained in:
@@ -34,6 +34,17 @@ export const ENV_OVERRIDES = {
|
||||
GIT_PAGER: 'cat',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* UTF-8 I/O pinning prepended to every command. The subprocess collector
|
||||
* decodes output bytes as UTF-8, but Windows PowerShell 5.1 (the last-resort
|
||||
* executable fallback) writes the console/OEM code page by default, which
|
||||
* garbles non-ASCII output; pwsh 7 defaults to UTF-8 and is unaffected. The
|
||||
* statements ride on line 1 after `; ` separators so PowerShell error line
|
||||
* numbers stay accurate.
|
||||
*/
|
||||
export const ENCODING_PREAMBLE =
|
||||
'[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); '
|
||||
|
||||
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config). */
|
||||
const DEFAULT_GRACE_MS = 3_000
|
||||
|
||||
@@ -197,7 +208,7 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
const collect = (maxBytes: number): SubprocessCollect =>
|
||||
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
|
||||
return {
|
||||
argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', spec.command],
|
||||
argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`],
|
||||
cwd: spec.workdir,
|
||||
stdio: {
|
||||
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
|
||||
@@ -307,7 +318,10 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
|
||||
/**
|
||||
* Settlement hook for subclasses that attach execution facts to a process.
|
||||
* The base implementation is intentionally empty.
|
||||
* The base implementation is intentionally empty. Mirrored from
|
||||
* `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is
|
||||
* the declared seam for a future pwsh-confining subclass and has no consumer
|
||||
* in this package yet.
|
||||
* @param _proc - the settled process handle.
|
||||
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
|
||||
*/
|
||||
|
||||
@@ -15,13 +15,17 @@ import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { PwshLocalExecutor, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import SubprocessService from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-'))
|
||||
|
||||
const hasPwsh = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
// The probe follows the executor's own resolution (Program Files installs on
|
||||
// Windows are found even when bare `pwsh` is not on PATH).
|
||||
const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
|
||||
/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */
|
||||
const lf = (text: string): string => text.replace(/\r\n/g, '\n')
|
||||
@@ -110,6 +114,42 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
|
||||
})
|
||||
})
|
||||
|
||||
describe('spawn construction (pure, every platform)', () => {
|
||||
/** A subprocess service that records spawn specs and settles instantly. */
|
||||
class CapturingSubprocessService extends SubprocessService {
|
||||
specs: SubprocessSpawnSpec[] = []
|
||||
private readonly reader: SubprocessOutputReader = {
|
||||
readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
|
||||
}
|
||||
override spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
|
||||
this.specs.push(spec)
|
||||
return {
|
||||
pid: -1,
|
||||
stdin: undefined,
|
||||
stdout: undefined,
|
||||
stderr: undefined,
|
||||
collected: { stdout: this.reader, stderr: this.reader },
|
||||
done: Promise.resolve({ exitCode: 0, signal: null }),
|
||||
terminate: () => {},
|
||||
waitForExit: async () => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('runs every command as ONE argv element under the UTF-8 encoding preamble', async () => {
|
||||
const ctx = new Context()
|
||||
const subprocess = new CapturingSubprocessService(ctx)
|
||||
await ctx.plugin(PwshLocalExecutor)
|
||||
await ctx.bash.run(ctx.bash.resolve({ command: 'Write-Output 你好' }))
|
||||
expect(subprocess.specs).toHaveLength(1)
|
||||
const { argv } = subprocess.specs[0]!
|
||||
expect(argv.slice(0, 5)).toEqual([expect.any(String), '-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
|
||||
expect(argv[5]).toBe(`${ENCODING_PREAMBLE}Write-Output 你好`)
|
||||
expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding')
|
||||
expect(ENCODING_PREAMBLE).toContain('$OutputEncoding')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
|
||||
it('resolves with output and the effective timeout', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 5_000 })
|
||||
|
||||
Reference in New Issue
Block a user