From e91f22832771d7a530915702ff14e045d9001955 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 05:13:47 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=20Windows=20trans=20?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/ssh.test.ts | 45 ++++++++++++++++++++++++++++++---- scripts/src/ssh.ts | 54 ++++++++++++++++++++++++++++++++--------- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index 29403439..a7226a4b 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -49,7 +49,11 @@ describe("ssh windows PowerShell safety prelude", () => { expect(prelude).toContain("'PSPath','PSParentPath','PSChildName','PSDrive','PSProvider','ReadCount'"); expect(prelude).toContain("Microsoft.PowerShell.Utility\\ConvertTo-Json"); expect(prelude).toContain("$PSDefaultParameterValues['Get-Content:Encoding'] = 'utf8'"); - expect(prelude).toContain("Set-Location -LiteralPath 'C:\\test'"); + expect(prelude).toContain("[System.IO.Directory]::Exists($unideskRouteCwd)"); + expect(prelude).toContain("Set-Location -LiteralPath $unideskRouteCwd -ErrorAction Stop"); + expect(prelude).toContain("[Environment]::CurrentDirectory = (Get-Location).ProviderPath"); + expect(prelude).toContain("UNIDESK_SSH_CWD_FAILED"); + expect(prelude).toContain("trans-windows-route-cwd-invalid"); }); }); @@ -182,6 +186,37 @@ describe("ssh windows fs read-only operations", () => { }); }); + test("requires one route cwd for PowerShell, cmd, and their stdin launchers", () => { + for (const operation of ["ps", "cmd"] as const) { + try { + parseSshInvocation("D601:win", [operation]); + throw new Error(`expected ${operation} without a route cwd to fail`); + } catch (error) { + expect(error).toMatchObject({ + code: "trans-windows-route-cwd-required", + argument: "D601:win", + }); + expect((error as Error & { hint?: string }).hint).toContain("quoted heredoc"); + } + } + + const powerShell = parseSshInvocation("D601:win/d/work/研究资料归档-李昂", ["ps"]); + const cmd = parseSshInvocation("D601:win/d/work/研究资料归档-李昂", ["cmd"]); + const powerShellLauncher = decodedWindowsPowerShellCommand(powerShell.parsed.remoteCommand); + const cmdLauncher = decodedWindowsPowerShellCommand(cmd.parsed.remoteCommand); + + expect(powerShell.parsed.requiresStdin).toBe(true); + expect(cmd.parsed.requiresStdin).toBe(true); + for (const launcher of [powerShellLauncher, cmdLauncher]) { + expect(launcher).toContain("D:\\work\\研究资料归档-李昂"); + expect(launcher).toContain("UNIDESK_SSH_CWD_FAILED"); + expect(launcher).toContain("trans-windows-route-cwd-invalid"); + expect(launcher).toContain("[Environment]::CurrentDirectory = (Get-Location).ProviderPath"); + expect(launcher).toContain("[Console]::In.ReadToEnd()"); + } + expect(cmdLauncher).not.toContain("cd /d"); + }); + test("rejects unsupported Windows read operations instead of treating them as POSIX", () => { expect(() => parseSshInvocation("D601:win/c/test", ["sed", "-n", "1p", "hello.md"])).toThrow("unsupported ssh win operation: sed"); }); @@ -355,7 +390,7 @@ describe("ssh stdout bounded streaming", () => { }); test("formats truncation hint without echoing remote command", () => { - const invocation = parseSshInvocation("D601:win", ["ps"]); + const invocation = parseSshInvocation("D601:win/c/test", ["ps"]); expect(invocation.parsed.remoteCommand).not.toBeNull(); const hint = sshStdoutTruncationHint({ invocation, @@ -370,7 +405,7 @@ describe("ssh stdout bounded streaming", () => { const payload = JSON.parse(formatted.slice("UNIDESK_SSH_STDOUT_TRUNCATED ".length)) as Record; expect(payload.code).toBe("ssh-stdout-truncated"); expect(payload.providerId).toBe("D601"); - expect(payload.route).toBe("D601:win"); + expect(payload.route).toBe("D601:win/c/test"); expect(payload.thresholdBytes).toBe(4096); expect(payload.disclosurePolicy).toMatchObject({ name: "unified-cli-dump-preview", @@ -380,7 +415,7 @@ describe("ssh stdout bounded streaming", () => { }); test("forwarder bounds stdout and emits one truncation hint", () => { - const invocation = parseSshInvocation("D601:win", ["ps"]); + const invocation = parseSshInvocation("D601:win/c/test", ["ps"]); const forwarded: Buffer[] = []; const stdout = { write(chunk: string | Buffer): boolean { @@ -423,7 +458,7 @@ describe("ssh stdout bounded streaming", () => { }); test("stderr forwarder uses the same dump guard and marker", () => { - const invocation = parseSshInvocation("D601:win", ["ps"]); + const invocation = parseSshInvocation("D601:win/c/test", ["ps"]); const forwarded: Buffer[] = []; const stderr = { write(chunk: string | Buffer): boolean { diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 89a8a972..0738af7e 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -547,6 +547,7 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs return parseWindowsGitInvocation(route, args.slice(1)); } if (operation === "ps" || operation === "powershell" || operation === "powershell.exe") { + requireWindowsRouteWorkspace(route, "ps"); const commandArgs = args[1] === "--" ? args.slice(2) : args.slice(1); if (commandArgs.length >= 2 && (commandArgs[0] === "-File" || commandArgs[0] === "-file")) { const fileArgs = [commandArgs[1]?.replace(/\\/g, "/") ?? "", ...commandArgs.slice(2)]; @@ -581,6 +582,7 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs if (operation !== "cmd" && operation !== "cmd.exe") { throw new Error(windowsUnsupportedOperationMessage(route, operation)); } + requireWindowsRouteWorkspace(route, "cmd"); const commandArgs = args[1] === "--" ? args.slice(2) : args.slice(1); if (commandArgs.length === 0) { return { @@ -590,12 +592,29 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs }; } return { - remoteCommand: buildWindowsPowerShellInvocation(buildWindowsCmdLauncherScript(buildWindowsCmdLine(commandArgs.map((value) => windowsCmdArgument(value, route)).join(" "), route.workspace))), + remoteCommand: buildWindowsPowerShellInvocation(buildWindowsCmdLauncherScript( + buildWindowsCmdLine(commandArgs.map((value) => windowsCmdArgument(value, route)).join(" ")), + route.workspace, + )), requiresStdin: false, invocationKind: "argv", }; } +function requireWindowsRouteWorkspace(route: ParsedSshRoute, operation: "ps" | "cmd"): asserts route is ParsedSshRoute & { workspace: string } { + if (route.workspace !== null) return; + const entrypoint = sshDisplayEntrypoint(); + const routeTemplate = `${route.providerId}:win//`; + throw new CliInputError(`Windows ${operation} requires an explicit route workspace; refusing to inherit C:\\Windows`, { + code: "trans-windows-route-cwd-required", + argument: route.raw, + usage: operation === "ps" + ? [`${entrypoint} ${routeTemplate} ps ''`, `${entrypoint} ${routeTemplate} ps <<'PS'`] + : [`${entrypoint} ${routeTemplate} cmd [args...]`, `${entrypoint} ${routeTemplate} cmd <<'CMD'`], + hint: "Put the Windows drive and working directory in the route. Send multiline source directly through a quoted heredoc on stdin.", + }); +} + function looksLikeLocallyExpandedPowerShellSource(command: string): boolean { return /(?:^|[;{}]\s*)=\s*[^=]/u.test(command); } @@ -628,7 +647,7 @@ function parseWindowsGitInvocation(route: ParsedSshRoute, gitArgs: string[]): Pa if (subcommand === "commit") validateWindowsGitCommitArgs(route, gitArgs); const commandLine = ["git", ...gitArgs].map((value) => windowsCmdArgument(value, route)).join(" "); return { - remoteCommand: buildWindowsPowerShellInvocation(buildWindowsCmdLauncherScript(buildWindowsCmdLine(commandLine, route.workspace))), + remoteCommand: buildWindowsPowerShellInvocation(buildWindowsCmdLauncherScript(buildWindowsCmdLine(commandLine), route.workspace)), requiresStdin: false, invocationKind: "argv", }; @@ -769,22 +788,16 @@ function validateWindowsGitCommitArgs(route: ParsedSshRoute, gitArgs: string[]): throw new Error(`ssh win git commit would open an editor; use ${entrypoint} ${route.raw} git commit -m , ${entrypoint} ${route.raw} git commit --no-edit, or wrap an interactive command with ${entrypoint} ${route.raw} ps <<'PS'`); } -function buildWindowsCmdLine(userCommand: string, cwd: string | null): string { +function buildWindowsCmdLine(userCommand: string): string { const parts = [ "chcp 65001>nul", 'set "PYTHONUTF8=1"', 'set "PYTHONIOENCODING=utf-8"', ]; - if (cwd !== null) parts.push(`cd /d ${windowsCmdQuote(cwd)}`); parts.push(userCommand); return parts.join(" && "); } -function windowsCmdQuote(value: string): string { - if (/[\r\n"]/u.test(value)) throw new Error("ssh win workspace path must not contain quotes or newlines"); - return `"${value}"`; -} - function windowsCmdArgument(value: string, route: ParsedSshRoute): string { if (value.length === 0) return "\"\""; if (/[\r\n"]/u.test(value) || /[&|<>^%!]/u.test(value)) { @@ -883,7 +896,23 @@ export function powerShellSingleQuote(value: string): string { return `'${value.replace(/'/g, "''")}'`; } -function buildWindowsCmdLauncherScript(cmdLine: string): string { +function windowsPowerShellRouteCwdGuardLines(cwd: string | null): string[] { + if (cwd === null) return []; + return [ + `$unideskRouteCwd = ${powerShellSingleQuote(cwd)};`, + "try {", + " if (-not [System.IO.Directory]::Exists($unideskRouteCwd)) { throw 'route cwd unavailable' }", + " Set-Location -LiteralPath $unideskRouteCwd -ErrorAction Stop;", + " [Environment]::CurrentDirectory = (Get-Location).ProviderPath;", + "} catch {", + " $unideskCwdError = [ordered]@{ code = 'trans-windows-route-cwd-invalid'; failureKind = 'cwd-failed'; cwd = $unideskRouteCwd; message = 'requested Windows route cwd does not exist or is not accessible' };", + " [Console]::Error.WriteLine('UNIDESK_SSH_CWD_FAILED ' + ($unideskCwdError | Microsoft.PowerShell.Utility\\ConvertTo-Json -Compress));", + " exit 2;", + "}", + ]; +} + +function buildWindowsCmdLauncherScript(cmdLine: string, cwd: string | null): string { return [ "$ErrorActionPreference = 'Stop';", "[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();", @@ -891,6 +920,7 @@ function buildWindowsCmdLauncherScript(cmdLine: string): string { "$OutputEncoding = [System.Text.UTF8Encoding]::new();", "$env:PYTHONUTF8 = '1';", "$env:PYTHONIOENCODING = 'utf-8';", + ...windowsPowerShellRouteCwdGuardLines(cwd), `& ${powerShellSingleQuote(windowsCmdExeNativePath)} /d /s /c ${powerShellSingleQuote(cmdLine)};`, "exit $LASTEXITCODE;", ].join(" "); @@ -902,7 +932,6 @@ function buildWindowsCmdStdinLauncherScript(cwd: string | null): string { "chcp 65001>nul", 'set "PYTHONUTF8=1"', 'set "PYTHONIOENCODING=utf-8"', - ...(cwd === null ? [] : [`cd /d ${windowsCmdQuote(cwd)}`]), ]; return [ "$ErrorActionPreference = 'Stop';", @@ -912,6 +941,7 @@ function buildWindowsCmdStdinLauncherScript(cwd: string | null): string { "$OutputEncoding = [System.Text.UTF8Encoding]::new();", "$env:PYTHONUTF8 = '1';", "$env:PYTHONIOENCODING = 'utf-8';", + ...windowsPowerShellRouteCwdGuardLines(cwd), "$script = [Console]::In.ReadToEnd();", "$script = $script -replace \"`r`n\", \"`n\";", "$script = $script -replace \"`r\", \"`n\";", @@ -993,8 +1023,8 @@ export function windowsPowerShellScriptPrelude(cwd: string | null): string { "$PSDefaultParameterValues['Get-Content:Encoding'] = 'utf8'", "$env:PYTHONUTF8 = '1'", "$env:PYTHONIOENCODING = 'utf-8'", + ...windowsPowerShellRouteCwdGuardLines(cwd), ...windowsPowerShellJsonSafetyPreludeLines, - ...(cwd === null ? [] : [`Set-Location -LiteralPath ${powerShellSingleQuote(cwd)}`]), ].join("\r\n") + "\r\n"; }