Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions crates/bashkit-js/__test__/error-handling.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,25 @@ test("unclosed quote returns non-zero or handles gracefully", (t) => {
// Should either error or handle gracefully
t.is(typeof r.exitCode, "number");
});

// ============================================================================
// Pre-exec failure stderr surfacing (issue #606)
// ============================================================================

test("pre-exec parse error surfaces in stderr (Bash)", (t) => {
const bash = new Bash();
const r = bash.executeSync("echo $(");
t.not(r.exitCode, 0);
t.truthy(r.error, "error field should contain the parse error");
t.truthy(r.stderr, "stderr must not be empty on pre-exec failure");
t.true(r.stderr.includes(r.error!), "stderr should contain the error message");
});

test("pre-exec parse error surfaces in stderr (BashTool)", (t) => {
const tool = new BashTool();
const r = tool.executeSync("echo $(");
t.not(r.exitCode, 0);
t.truthy(r.error, "error field should contain the parse error");
t.truthy(r.stderr, "stderr must not be empty on pre-exec failure");
t.true(r.stderr.includes(r.error!), "stderr should contain the error message");
});
60 changes: 36 additions & 24 deletions crates/bashkit-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,15 @@ impl Bash {
exit_code: result.exit_code,
error: None,
}),
Err(e) => Ok(ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 1,
error: Some(e.to_string()),
}),
Err(e) => {
let msg = e.to_string();
Ok(ExecResult {
stdout: String::new(),
stderr: msg.clone(),
exit_code: 1,
error: Some(msg),
})
}
}
})
}
Expand All @@ -140,12 +143,15 @@ impl Bash {
exit_code: result.exit_code,
error: None,
}),
Err(e) => Ok(ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 1,
error: Some(e.to_string()),
}),
Err(e) => {
let msg = e.to_string();
Ok(ExecResult {
stdout: String::new(),
stderr: msg.clone(),
exit_code: 1,
error: Some(msg),
})
}
}
}

Expand Down Expand Up @@ -269,12 +275,15 @@ impl BashTool {
exit_code: result.exit_code,
error: None,
}),
Err(e) => Ok(ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 1,
error: Some(e.to_string()),
}),
Err(e) => {
let msg = e.to_string();
Ok(ExecResult {
stdout: String::new(),
stderr: msg.clone(),
exit_code: 1,
error: Some(msg),
})
}
}
})
}
Expand All @@ -291,12 +300,15 @@ impl BashTool {
exit_code: result.exit_code,
error: None,
}),
Err(e) => Ok(ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 1,
error: Some(e.to_string()),
}),
Err(e) => {
let msg = e.to_string();
Ok(ExecResult {
stdout: String::new(),
stderr: msg.clone(),
exit_code: 1,
error: Some(msg),
})
}
}
}

Expand Down
14 changes: 12 additions & 2 deletions crates/bashkit-python/bashkit/deepagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ def _make_bash_tool(bash_instance: NativeBashTool):
def bashkit(command: str) -> str:
result = bash_instance.execute_sync(command)
output = result.stdout
if result.error:
output += f"\nError: {result.error}"
if result.stderr:
output += f"\n{result.stderr}"
if result.exit_code != 0:
Expand Down Expand Up @@ -134,7 +136,10 @@ def tools(self):
def execute_sync(self, command: str) -> str:
"""Execute command synchronously (for setup scripts)."""
result = self._bash.execute_sync(command)
return result.stdout + (result.stderr or "")
output = result.stdout + (result.stderr or "")
if result.error and result.error not in output:
output += f"\nError: {result.error}"
return output

def reset(self) -> None:
"""Reset VFS to initial state."""
Expand Down Expand Up @@ -189,6 +194,8 @@ def create_middleware(self) -> BashkitMiddleware:
def execute(self, command: str) -> ExecuteResponse:
result = self._bash.execute_sync(command)
output = result.stdout + (result.stderr or "")
if result.error and result.error not in output:
output += f"\nError: {result.error}"
return ExecuteResponse(output=output, exit_code=result.exit_code, truncated=False)

async def aexecute(self, command: str) -> ExecuteResponse:
Expand Down Expand Up @@ -344,7 +351,10 @@ async def aupload_files(self, files: list[tuple[str, bytes]]) -> list[FileUpload
def setup(self, script: str) -> str:
"""Execute setup script."""
result = self._bash.execute_sync(script)
return result.stdout + (result.stderr or "")
output = result.stdout + (result.stderr or "")
if result.error and result.error not in output:
output += f"\nError: {result.error}"
return output

def reset(self) -> None:
"""Reset VFS."""
Expand Down
2 changes: 2 additions & 0 deletions crates/bashkit-python/bashkit/pydantic_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ async def bash(commands: str) -> str:
result = await native.execute(commands)

output = result.stdout
if result.error:
output += f"\nError: {result.error}"
if result.stderr:
output += f"\nSTDERR: {result.stderr}"
if result.exit_code != 0:
Expand Down
30 changes: 18 additions & 12 deletions crates/bashkit-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,12 +268,15 @@ impl PyBash {
exit_code: result.exit_code,
error: None,
}),
Err(e) => Ok(ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 1,
error: Some(e.to_string()),
}),
Err(e) => {
let msg = e.to_string();
Ok(ExecResult {
stdout: String::new(),
stderr: msg.clone(),
exit_code: 1,
error: Some(msg),
})
}
}
})
}
Expand Down Expand Up @@ -476,12 +479,15 @@ impl BashTool {
exit_code: result.exit_code,
error: None,
}),
Err(e) => Ok(ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 1,
error: Some(e.to_string()),
}),
Err(e) => {
let msg = e.to_string();
Ok(ExecResult {
stdout: String::new(),
stderr: msg.clone(),
exit_code: 1,
error: Some(msg),
})
}
}
})
}
Expand Down
38 changes: 38 additions & 0 deletions crates/bashkit-python/tests/test_bashkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -954,3 +954,41 @@ def test_deeply_nested_schema_rejected():
tool = ScriptedTool("deep")
with pytest.raises(ValueError, match="nesting depth"):
tool.add_tool("deep", "Deep", callback=lambda p, s=None: "", schema=nested)


# ===========================================================================
# Pre-exec failure stderr surfacing (issue #606)
# ===========================================================================


def test_bash_pre_exec_error_in_stderr():
"""Pre-exec failures (parse errors) must appear in stderr, not only error field."""
bash = Bash()
# Unclosed subshell triggers parse error -> Err path in bindings
r = bash.execute_sync("echo $(")
assert r.exit_code != 0
assert r.error is not None
# Bug #606: stderr was empty even though error had the message
assert r.stderr != "", "stderr must contain the error message, not be empty"
assert r.error in r.stderr


def test_bashtool_pre_exec_error_in_stderr():
"""BashTool pre-exec failures must also surface in stderr."""
tool = BashTool()
r = tool.execute_sync("echo $(")
assert r.exit_code != 0
assert r.error is not None
assert r.stderr != "", "stderr must contain the error message, not be empty"
assert r.error in r.stderr


@pytest.mark.asyncio
async def test_bash_pre_exec_error_in_stderr_async():
"""Async path should also surface pre-exec errors in stderr."""
bash = Bash()
r = await bash.execute("echo $(")
assert r.exit_code != 0
assert r.error is not None
assert r.stderr != "", "stderr must contain the error message, not be empty"
assert r.error in r.stderr
Loading