fix: 改进 Windows SSH PowerShell 错误提示
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
sshRuntimeTimeoutMs,
|
||||
sshRuntimeTimingHint,
|
||||
sshWindowsPlaneHint,
|
||||
sshWindowsPowerShellPipelineHint,
|
||||
wrapSshRemoteCommand,
|
||||
type SshCaptureResult,
|
||||
} from "./ssh";
|
||||
@@ -435,6 +436,8 @@ async function runRemoteSshWebSocket(
|
||||
if (!timedOut) {
|
||||
const windowsPlaneHint = sshWindowsPlaneHint(invocation, code, stderrTail);
|
||||
if (windowsPlaneHint) process.stderr.write(windowsPlaneHint);
|
||||
const pipelineHint = sshWindowsPowerShellPipelineHint(invocation, code, stderrTail);
|
||||
if (pipelineHint) process.stderr.write(pipelineHint);
|
||||
}
|
||||
const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({
|
||||
invocation,
|
||||
@@ -615,6 +618,8 @@ async function runRemoteSshWebSocketCaptureRemoteCommand(
|
||||
startedAtMs,
|
||||
}));
|
||||
if (timingHint) stderr += timingHint;
|
||||
stderr += sshWindowsPlaneHint(invocation, code, stderr);
|
||||
stderr += sshWindowsPowerShellPipelineHint(invocation, code, stderr);
|
||||
resolve({ exitCode: code, stdout, stderr });
|
||||
};
|
||||
const openTimer = setTimeout(() => {
|
||||
|
||||
@@ -117,6 +117,20 @@ export function sshWindowsPlaneHint(invocation: ParsedSshInvocation, exitCode: n
|
||||
})}\n`;
|
||||
}
|
||||
|
||||
export function sshWindowsPowerShellPipelineHint(invocation: ParsedSshInvocation, exitCode: number, stderrText: string): string {
|
||||
if (invocation.route.plane !== "win" || exitCode === 0 || !/An empty pipe element is not allowed/iu.test(stderrText)) return "";
|
||||
return `UNIDESK_SSH_HINT ${JSON.stringify({
|
||||
code: "windows-powershell-statement-pipeline-boundary",
|
||||
providerId: safeProviderId(invocation.providerId),
|
||||
route: invocation.route.raw,
|
||||
exitCode,
|
||||
message: "PowerShell cannot pipe a statement such as foreach directly at this syntax boundary.",
|
||||
action: "Wrap the statement in @(...), then pipe the resulting values, or use a multiline quoted PowerShell heredoc.",
|
||||
examples: ["@(foreach (...) { ... }) | Format-List", "trans <route> ps <<'PS'"],
|
||||
note: "This hint intentionally does not echo the submitted PowerShell source.",
|
||||
})}\n`;
|
||||
}
|
||||
|
||||
export function sshSlowWarningThresholdMs(env: NodeJS.ProcessEnv = process.env): number {
|
||||
const raw = env.UNIDESK_SSH_SLOW_WARNING_MS;
|
||||
const parsed = raw === undefined ? NaN : Number(raw);
|
||||
@@ -1015,6 +1029,7 @@ async function runSshCaptureRemoteCommand(config: UniDeskConfig, invocation: Par
|
||||
stderrText: stderr,
|
||||
}));
|
||||
stderr += sshWindowsPlaneHint(invocation, exitCode, stderr);
|
||||
stderr += sshWindowsPowerShellPipelineHint(invocation, exitCode, stderr);
|
||||
resolve({ exitCode, stdout, stderr });
|
||||
};
|
||||
const runtimeTimer = setTimeout(() => {
|
||||
@@ -1538,6 +1553,8 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
|
||||
if (!timedOut) {
|
||||
const windowsPlaneHint = sshWindowsPlaneHint(invocation, exitCode, stderrTail);
|
||||
if (windowsPlaneHint) process.stderr.write(windowsPlaneHint);
|
||||
const pipelineHint = sshWindowsPowerShellPipelineHint(invocation, exitCode, stderrTail);
|
||||
if (pipelineHint) process.stderr.write(pipelineHint);
|
||||
}
|
||||
if (!timedOut) {
|
||||
const tcpPoolHint = formatSshTcpPoolHint(sshTcpPoolHint({
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
sshStdoutStreamMaxBytes,
|
||||
sshStdoutTruncationHint,
|
||||
sshWindowsPlaneHint,
|
||||
sshWindowsPowerShellPipelineHint,
|
||||
windowsFsReadOnlyScript,
|
||||
windowsPowerShellScriptPrelude,
|
||||
} from "./ssh";
|
||||
@@ -95,10 +96,25 @@ describe("ssh windows fs read-only operations", () => {
|
||||
expect(decoded).toContain("update-ref");
|
||||
expect(decoded).toContain("unselected worktree/index fingerprint");
|
||||
expect(decoded).toContain("old-value CAS");
|
||||
expect(decoded).toContain("$ErrorActionPreference='Continue'");
|
||||
expect(decoded).toContain("2>&1");
|
||||
expect(decoded).toContain("Management.Automation.ErrorRecord");
|
||||
expect(decoded).toContain("warnings=@($gitWarnings)");
|
||||
expect(decoded.trimEnd()).toEndWith("exit 0");
|
||||
expect(() => parseSshInvocation("D601:win/F/Work/ConStart", ["git", "exact-commit", "run", "--path", "src/main.c", "--message", "fix: exact"])).toThrow("requires --confirm");
|
||||
expect(() => parseSshInvocation("D601:win/F/Work/ConStart", ["git", "exact-commit", "plan", "--path", "../secret"])).toThrow("must stay inside");
|
||||
});
|
||||
|
||||
test("adds a bounded hint for the PowerShell statement-to-pipeline parser boundary", () => {
|
||||
const invocation = parseSshInvocation("D601:win/c/test", ["ps", "foreach ($x in $xs) { $x } | Format-List"]);
|
||||
const hint = sshWindowsPowerShellPipelineHint(invocation, 1, "An empty pipe element is not allowed");
|
||||
|
||||
expect(hint).toContain("windows-powershell-statement-pipeline-boundary");
|
||||
expect(hint).toContain("@(foreach (...) { ... }) | Format-List");
|
||||
expect(hint).not.toContain("$x in $xs");
|
||||
expect(sshWindowsPowerShellPipelineHint(invocation, 0, "An empty pipe element is not allowed")).toBe("");
|
||||
});
|
||||
|
||||
test("builds bounded UTF-8 scripts for cat, ls, and rg", () => {
|
||||
const catScript = windowsFsReadOnlyScript("F:\\Work\\demo", "cat", ["--max-bytes", "4096", "中文.md"]);
|
||||
const lsScript = windowsFsReadOnlyScript("F:\\Work\\demo", "ls", ["-la", "--limit=5"]);
|
||||
|
||||
+21
-8
@@ -657,10 +657,21 @@ Set-Location -LiteralPath ${cwd}
|
||||
$operation=${powerShellSingleQuote(operation)}
|
||||
$paths=@(${pathList})
|
||||
$message=${powerShellSingleQuote(message)}
|
||||
$gitWarnings=[Collections.Generic.List[string]]::new()
|
||||
function Invoke-Git([string[]]$GitArgs) {
|
||||
$output = @(& git @GitArgs 2>&1)
|
||||
if ($LASTEXITCODE -ne 0) { throw (($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine) }
|
||||
return (($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine).Trim()
|
||||
$previousErrorActionPreference=$ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference='Continue'
|
||||
$records=@(& git @GitArgs 2>&1)
|
||||
$nativeExitCode=$LASTEXITCODE
|
||||
$stderr=(($records | Where-Object { $_ -is [Management.Automation.ErrorRecord] } | ForEach-Object { $_.Exception.Message }) -join [Environment]::NewLine).Trim()
|
||||
$stdout=(($records | Where-Object { $_ -isnot [Management.Automation.ErrorRecord] } | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine).Trim()
|
||||
if($nativeExitCode -ne 0){throw ((@($stdout,$stderr) | Where-Object { $_ }) -join [Environment]::NewLine)}
|
||||
if($stderr){[void]$gitWarnings.Add($stderr)}
|
||||
return $stdout
|
||||
} finally {
|
||||
$ErrorActionPreference=$previousErrorActionPreference
|
||||
}
|
||||
}
|
||||
function Object-Status {
|
||||
$values=@{}
|
||||
@@ -671,13 +682,13 @@ $before=Object-Status
|
||||
$head=Invoke-Git @('rev-parse','--verify','HEAD^{commit}')
|
||||
$branch=Invoke-Git @('symbolic-ref','-q','HEAD')
|
||||
if($operation -eq 'status') {
|
||||
[ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;objects=$before;safeStop='read-only; no objects were pruned'} | ConvertTo-Json -Depth 8 -Compress
|
||||
[ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;objects=$before;warnings=@($gitWarnings);safeStop='read-only; no objects were pruned'} | ConvertTo-Json -Depth 8 -Compress
|
||||
exit 0
|
||||
}
|
||||
if($operation -eq 'gc-auto') {
|
||||
$gcOutput=Invoke-Git @('gc','--auto')
|
||||
$after=Object-Status
|
||||
[ordered]@{ok=$true;operation=$operation;head=$head;before=$before;after=$after;gcOutput=$gcOutput;safeStop='git gc --auto only; Git recent-object and reflog retention remain authoritative; no direct prune'} | ConvertTo-Json -Depth 8 -Compress
|
||||
[ordered]@{ok=$true;operation=$operation;head=$head;before=$before;after=$after;gcOutput=$gcOutput;warnings=@($gitWarnings);safeStop='git gc --auto only; Git recent-object and reflog retention remain authoritative; no direct prune'} | ConvertTo-Json -Depth 8 -Compress
|
||||
exit 0
|
||||
}
|
||||
$gitDir=Invoke-Git @('rev-parse','--path-format=absolute','--git-dir')
|
||||
@@ -693,7 +704,7 @@ try {
|
||||
$parentTree=Invoke-Git @('rev-parse',($head+'^{tree}'))
|
||||
if($tree -eq $parentTree){throw 'selected paths do not change the HEAD tree'}
|
||||
if($operation -eq 'plan') {
|
||||
[ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;tree=$tree;paths=$paths;tempIndex=$tempIndex;objectsBefore=$before;safeStop='plan only; ref and original index unchanged'} | ConvertTo-Json -Depth 8 -Compress
|
||||
[ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;tree=$tree;paths=$paths;tempIndex=$tempIndex;objectsBefore=$before;warnings=@($gitWarnings);safeStop='plan only; ref and original index unchanged'} | ConvertTo-Json -Depth 8 -Compress
|
||||
exit 0
|
||||
}
|
||||
$commit=($message | & git commit-tree $tree -p $head).Trim()
|
||||
@@ -704,12 +715,13 @@ try {
|
||||
$unrelatedAfter=Invoke-Git (@('status','--porcelain=v2','--')+$unrelatedPathspec)
|
||||
if($unrelatedAfter -ne $unrelatedBefore){throw 'unselected worktree/index fingerprint changed after exact commit'}
|
||||
$after=Object-Status
|
||||
[ordered]@{ok=$true;operation=$operation;oldHead=$head;commit=$commit;tree=$tree;branch=$branch;paths=$paths;tempIndex=$tempIndex;unselectedStateUnchanged=$true;selectedPathsIndexUpdated=$true;objectsBefore=$before;objectsAfter=$after;safeStop='ref updated with old-value CAS; no prune performed'} | ConvertTo-Json -Depth 8 -Compress
|
||||
[ordered]@{ok=$true;operation=$operation;oldHead=$head;commit=$commit;tree=$tree;branch=$branch;paths=$paths;tempIndex=$tempIndex;unselectedStateUnchanged=$true;selectedPathsIndexUpdated=$true;objectsBefore=$before;objectsAfter=$after;warnings=@($gitWarnings);safeStop='ref updated with old-value CAS; no prune performed'} | ConvertTo-Json -Depth 8 -Compress
|
||||
} finally {
|
||||
Remove-Item Env:GIT_INDEX_FILE -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $tempIndex -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath ($tempIndex+'.lock') -Force -ErrorAction SilentlyContinue
|
||||
}`;
|
||||
}
|
||||
exit 0`;
|
||||
}
|
||||
|
||||
function validateWindowsGitCommitArgs(route: ParsedSshRoute, gitArgs: string[]): void {
|
||||
@@ -2058,6 +2070,7 @@ export {
|
||||
sshTcpPoolHint,
|
||||
sshTruncationCompletionSummary,
|
||||
sshWindowsPlaneHint,
|
||||
sshWindowsPowerShellPipelineHint,
|
||||
} from "./ssh-runtime";
|
||||
export type { SshStreamForwarder } from "./ssh-runtime";
|
||||
export { windowsFsReadOnlyScript } from "./ssh-windows-fs";
|
||||
|
||||
Reference in New Issue
Block a user