diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 8548731a28..b93bbb8d89 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -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. */ diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index 1794666911..5113f6a988 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -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 })