Merge pull request #1891 from pikasTech/fix/trans-windows-stdin-cwd
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

fix: 统一 Windows trans 工作目录
This commit is contained in:
Lyon
2026-07-13 11:15:40 +08:00
committed by GitHub
2 changed files with 82 additions and 17 deletions
+40 -5
View File
@@ -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<string, unknown>;
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 {
+42 -12
View File
@@ -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/<drive>/<path>`;
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 '<PowerShell source>'`, `${entrypoint} ${routeTemplate} ps <<'PS'`]
: [`${entrypoint} ${routeTemplate} cmd <command> [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 <message>, ${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";
}