fix: drain thread no longer crashes on fd-less stdout streams (#34789)

* docs(code-execution): document HERMES_* env narrowing + passthrough workaround

The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.

* fix: drain thread no longer crashes on fd-less stdout streams

The _wait_for_process drain thread called proc.stdout.fileno()
unconditionally. ProcessHandle implementations whose stdout is not
backed by a real OS fd (iterator-style in-memory streams, mock procs)
raised 'list_iterator' object has no attribute 'fileno' (or
'fileno() returned a non-integer' from select.select), killing the
daemon thread and silently losing all process output.

Resolve the fd defensively at the top of _drain; when stdout has no
usable integer fileno, fall back to draining it as an iterable (the
legacy 'for line in proc.stdout' contract). The real subprocess /
os.pipe-backed select() fast path is unchanged.
This commit is contained in:
Teknium
2026-05-29 12:16:57 -07:00
committed by GitHub
parent 5641ae6469
commit 90b3c54de9
2 changed files with 99 additions and 1 deletions
+43 -1
View File
@@ -524,8 +524,50 @@ class BaseEnvironment(ABC):
# U+FFFD substitution rather than clobbering the whole buffer.
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
def _drain_iterable(stream):
# Fallback path: ``stream`` is not backed by a real OS file
# descriptor (no usable ``fileno()``). This covers in-memory
# ProcessHandle adapters that expose stdout as a plain iterator of
# already-collected output (the legacy ``for line in proc.stdout``
# contract) rather than a live pipe. Iterate it to EOF. Without
# this, the drain thread would raise an unhandled exception and die
# silently, losing all of the process's output.
try:
for piece in stream:
if piece is None:
continue
if isinstance(piece, bytes):
output_chunks.append(decoder.decode(piece))
else:
output_chunks.append(str(piece))
except Exception:
pass
finally:
try:
tail = decoder.decode(b"", final=True)
if tail:
output_chunks.append(tail)
except Exception:
pass
def _drain():
fd = proc.stdout.fileno()
# Resolve a real OS file descriptor up front. Real subprocesses and
# the SDK ``_ThreadedProcessHandle`` (os.pipe-backed) both return an
# integer fd here. Mocks / iterator-style stdout streams either lack
# ``fileno()`` entirely or return a non-integer — in that case fall
# back to draining the stream as an iterable instead of crashing the
# thread (issue: 'list_iterator' object has no attribute 'fileno').
stream = proc.stdout
if stream is None:
return
fileno = getattr(stream, "fileno", None)
try:
fd = fileno() if callable(fileno) else None
except Exception:
fd = None
if not isinstance(fd, int) or fd < 0:
_drain_iterable(stream)
return
# select.select does NOT work on pipe fds on Windows (only sockets).
# Use blocking os.read in a daemon thread instead — safe because
# EOF arrives promptly when bash exits.