fix(shell): preserve powershell exit codes

Use a multiline PowerShell trailer so native Windows commands keep their actual exit status without masking cmdlet failures, and add focused regression coverage. Remove the accidentally committed .opencode package-lock to keep generated state out of the branch.
This commit is contained in:
LukeParkerDev 2026-04-03 14:27:03 +10:00
parent 6ad6358eb1
commit 23e77fd9bc
2 changed files with 49 additions and 4 deletions

View file

@ -11,9 +11,11 @@ export function preview(text: string) {
}
export namespace ShellRunner {
function wrap(name: string, command: string) {
if (name !== "powershell" && name !== "pwsh") return command
return `${command}; if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }; if ($?) { exit 0 }; exit 1`
function preserveExitCode(command: string) {
return `${command}
if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }
if ($?) { exit 0 }
exit 1`
}
export async function shellEnv(ctx: Tool.Context, cwd: string) {
@ -26,7 +28,7 @@ export namespace ShellRunner {
export function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && (name === "powershell" || name === "pwsh")) {
return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", wrap(name, command)], {
return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", preserveExitCode(command)], {
cwd,
env,
stdio: ["ignore", "pipe", "pipe"],

View file

@ -115,6 +115,15 @@ const each = (name: string, fn: (item: { label: string; shell: string }) => Prom
}
}
const eachps = (name: string, fn: (item: { label: string; shell: string }) => Promise<void>) => {
for (const item of ps) {
test(
`${name} [${item.label}]`,
withShell(item, () => fn(item)),
)
}
}
const capture = (requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
@ -1018,6 +1027,40 @@ describe("tool.shell runtime", () => {
})
})
eachps("preserves native exit code with trailing comment", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await getTool()
const result = await bash.execute(
{
command: `${js("process.exit(42)")} # keep wrapper separate`,
description: "Trailing comment exit",
},
ctx,
)
expect(result.metadata.exit).toBe(42)
},
})
})
eachps("returns non-zero exit for powershell cmdlet errors", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await getTool()
const result = await bash.execute(
{
command: "Write-Error x",
description: "Cmdlet error exit",
},
ctx,
)
expect(result.metadata.exit).toBe(1)
},
})
})
each("streams metadata updates progressively", async () => {
await Instance.provide({
directory: projectRoot,