diff --git a/scripts/src/remote-ssh.ts b/scripts/src/remote-ssh.ts index 8cad47fa..9983a545 100644 --- a/scripts/src/remote-ssh.ts +++ b/scripts/src/remote-ssh.ts @@ -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(() => { diff --git a/scripts/src/ssh-runtime.ts b/scripts/src/ssh-runtime.ts index 6a762edf..57976f5c 100644 --- a/scripts/src/ssh-runtime.ts +++ b/scripts/src/ssh-runtime.ts @@ -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 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({ diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index b28779c8..eca36817 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -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"]); diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 114af8df..04b000c6 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -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";