chore: import upstream snapshot with attribution
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:45 +08:00
commit 4b6817381b
3933 changed files with 525247 additions and 0 deletions
@@ -0,0 +1,132 @@
"""Execute planned opensre CLI actions.
Pure subprocess execution, planning, and orchestration for shell / synthetic /
Claude Code / opensre CLI action tools live under ``tools.interactive_shell``
(``subprocess/``, ``shell/``, ``synthetic/``, ``implementation/``, ``cli/``).
This package keeps Rich relay helpers in ``task_streaming``, the REPL presenter
in ``repl_presenter``, and backward-compatible surface adapters such as
``opensre_cli_runner`` for slash parity and legacy test monkeypatch paths.
Shell command execution lives in ``tools.interactive_shell.shell`` (parsing,
policy, ``execute_shell_command``, and the ``run_shell_command`` / ``run_cd`` /
``run_pwd`` runner); it is intentionally not re-exported here. Synthetic test
execution lives in ``tools.interactive_shell.synthetic`` (the
``run_synthetic_test`` / ``watch_synthetic_subprocess`` runner), Claude Code
implementation execution lives in ``tools.interactive_shell.implementation.claude_code_executor``
(``run_claude_code_implementation``), and sample-alert / free-text investigation
execution lives in ``tools.interactive_shell.actions.sample_alert`` /
``tools.interactive_shell.actions.investigation`` (``run_sample_alert`` /
``run_text_investigation``); none are re-exported here. Shared stdlib subprocess
primitives live in ``tools.interactive_shell.subprocess``; Rich stream relay
remains in ``task_streaming``.
Public API is stable: all names exported below are importable directly from
``subprocess_runner`` and will remain so regardless of internal submodule changes.
Stdlib modules ``os``, ``subprocess``, and ``threading`` are re-imported here so
that tests can patch them via the full ``subprocess_runner.<module>.<attr>`` path
(e.g. ``subprocess_runner.subprocess.Popen``). Since these are module singletons in
``sys.modules``, patching via this attribute also affects the actual call sites
inside the submodules.
"""
from __future__ import annotations
# Stdlib singletons — imported so that monkeypatch paths resolve correctly in tests:
# ``"…subprocess_runner.os.chdir"``, ``"…subprocess_runner.subprocess.Popen"``,
# ``"…subprocess_runner.threading.Thread"``, ``"…subprocess_runner.time.sleep"``,
# ``"…subprocess_runner.Path.cwd"``.
import os
import subprocess
import threading
import time
from pathlib import Path
from .background_task_executor import start_background_cli_task
from .opensre_cli_runner import (
_INTERACTIVE_OPENSRE_COMMAND_PATHS,
_OPENSRE_BLOCKED_SUBCOMMANDS,
OpensreCommandClass,
OpensreExecutionMode,
OpensreExecutionPlan,
OpensreRunOutcome,
OpensreRunResult,
_build_opensre_execution_plan,
_classify_opensre_command,
_is_interactive_wizard,
_opensre_confirmation_reason,
_run_opensre_foreground,
_run_opensre_foreground_streaming,
build_opensre_cli_argv,
print_interactive_wizard_handoff,
run_opensre_cli_command,
run_opensre_cli_command_result,
)
from .task_streaming import (
_MAX_COMMAND_OUTPUT_CHARS,
_MIN_SUBPROCESS_TERMINAL_WIDTH,
_SYNTHETIC_DIAG_CHARS,
_SYNTHETIC_POLL_SECONDS,
_TASK_OUTPUT_JOIN_TIMEOUT_SECONDS,
_TASK_OUTPUT_PREFIX_WIDTH,
CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS,
SHELL_COMMAND_TIMEOUT_SECONDS,
SYNTHETIC_TEST_TIMEOUT_SECONDS,
_console_file_is_tty,
_join_task_output_streams,
_print_task_output_line,
_pump_task_pty,
_pump_task_stream,
_should_use_pty,
_start_task_output_streams,
_subprocess_env_with_aligned_width,
read_diag,
read_task_output,
terminate_child_process,
)
__all__ = [
"CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS",
"SHELL_COMMAND_TIMEOUT_SECONDS",
"SYNTHETIC_TEST_TIMEOUT_SECONDS",
"OpensreCommandClass",
"OpensreExecutionMode",
"OpensreExecutionPlan",
"OpensreRunOutcome",
"OpensreRunResult",
"Path",
"_INTERACTIVE_OPENSRE_COMMAND_PATHS",
"_MAX_COMMAND_OUTPUT_CHARS",
"_MIN_SUBPROCESS_TERMINAL_WIDTH",
"_OPENSRE_BLOCKED_SUBCOMMANDS",
"_SYNTHETIC_DIAG_CHARS",
"_SYNTHETIC_POLL_SECONDS",
"_TASK_OUTPUT_JOIN_TIMEOUT_SECONDS",
"_TASK_OUTPUT_PREFIX_WIDTH",
"_classify_opensre_command",
"_build_opensre_execution_plan",
"_console_file_is_tty",
"_is_interactive_wizard",
"_join_task_output_streams",
"_opensre_confirmation_reason",
"_print_task_output_line",
"_pump_task_pty",
"_pump_task_stream",
"_run_opensre_foreground",
"_run_opensre_foreground_streaming",
"_should_use_pty",
"_start_task_output_streams",
"_subprocess_env_with_aligned_width",
"build_opensre_cli_argv",
"os",
"print_interactive_wizard_handoff",
"read_diag",
"read_task_output",
"run_opensre_cli_command",
"run_opensre_cli_command_result",
"start_background_cli_task",
"subprocess",
"terminate_child_process",
"threading",
"time",
]
@@ -0,0 +1,252 @@
"""Background CLI task launcher — runs subprocesses with streamed output above the prompt."""
from __future__ import annotations
import contextlib
import os
import subprocess
import tempfile
import threading
import time
from typing import Any
from rich.console import Console
from rich.markup import escape
from surfaces.interactive_shell.runtime import Session, TaskKind, TaskRecord
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
from surfaces.interactive_shell.utils.telemetry import PromptRecorder
from .task_streaming import (
_MAX_COMMAND_OUTPUT_CHARS,
_SYNTHETIC_DIAG_CHARS,
_SYNTHETIC_POLL_SECONDS,
SHELL_COMMAND_TIMEOUT_SECONDS,
_join_task_output_streams,
_pump_task_pty,
_should_use_pty,
_sr_resolve,
_start_task_output_streams,
_subprocess_env_with_aligned_width,
read_diag,
read_task_output,
terminate_child_process,
)
def _compose_task_log_response(
*,
headline: str,
stdout_buf: tempfile.SpooledTemporaryFile[bytes] | None, # type: ignore[type-arg]
stderr_buf: tempfile.SpooledTemporaryFile[bytes], # type: ignore[type-arg]
) -> str:
"""Build the prompt-log response text for a finished background task.
Combines a status headline with the captured stdout and stderr so the
flushed ``background_task`` event carries the real command output (the
error text the user sees in the terminal), not an empty assistant reply.
"""
parts = [headline]
stdout_text = read_task_output(stdout_buf, limit=_MAX_COMMAND_OUTPUT_CHARS)
stderr_text = read_task_output(stderr_buf, limit=_MAX_COMMAND_OUTPUT_CHARS)
if stdout_text:
parts.append(stdout_text)
if stderr_text:
parts.append(stderr_text)
return "\n".join(parts)
def start_background_cli_task(
*,
display_command: str,
argv_list: list[str],
session: Session,
console: Console,
timeout_seconds: int = SHELL_COMMAND_TIMEOUT_SECONDS,
kind: TaskKind = TaskKind.CLI_COMMAND,
use_pty: bool = False,
) -> TaskRecord | None:
"""Start a subprocess as a REPL task while streaming output above the prompt."""
console.print(f"[bold]$ {display_command}[/bold]")
task = session.task_registry.create(kind, command=display_command)
task.mark_running()
# Created at launch so the flushed prompt-log latency spans the full task
# duration; the watcher sets the response and flushes once the outcome
# (including any error text) is known. See for_background_task() docstring.
recorder = PromptRecorder.for_background_task(
session=session, command=display_command, task_id=task.task_id
)
stderr_buf: tempfile.SpooledTemporaryFile[bytes] = tempfile.SpooledTemporaryFile( # type: ignore[type-arg]
max_size=_SYNTHETIC_DIAG_CHARS * 2
)
pty_fds: tuple[int, int] | None = None
if _should_use_pty(console, use_pty):
try:
pty_fds = os.openpty()
except OSError:
pty_fds = None
stdout_buf: tempfile.SpooledTemporaryFile[bytes] | None = None # type: ignore[type-arg]
if pty_fds is None:
stdout_buf = tempfile.SpooledTemporaryFile( # type: ignore[type-arg]
max_size=_MAX_COMMAND_OUTPUT_CHARS
)
subprocess_env = _subprocess_env_with_aligned_width(console)
proc: subprocess.Popen[Any]
try:
if pty_fds is None:
proc = subprocess.Popen(
argv_list,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
start_new_session=True,
env=subprocess_env,
)
else:
_master_fd, slave_fd = pty_fds
proc = subprocess.Popen(
argv_list,
stdin=subprocess.DEVNULL,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True,
start_new_session=True,
env=subprocess_env,
)
except Exception as exc: # noqa: BLE001
if pty_fds is not None:
for fd in pty_fds:
with contextlib.suppress(OSError):
os.close(fd)
task.mark_failed(str(exc))
if recorder is not None:
with contextlib.suppress(Exception):
recorder.set_error("spawn_failed", str(exc))
recorder.set_response(
_compose_task_log_response(
headline=f"command failed to start: {exc}",
stdout_buf=stdout_buf,
stderr_buf=stderr_buf,
)
)
recorder.flush()
if stdout_buf is not None:
stdout_buf.close()
stderr_buf.close()
report_exception(exc, context="surfaces.interactive_shell.background_cli_task.start")
console.print(f"[{ERROR}]failed to start:[/] {escape(str(exc))}")
return None
task.attach_process(proc)
started_at = time.monotonic()
if pty_fds is None:
output_threads = _start_task_output_streams(
task=task,
proc=proc,
console=console,
stdout_capture=stdout_buf,
stderr_capture=stderr_buf,
)
else:
master_fd, slave_fd = pty_fds
with contextlib.suppress(OSError):
os.close(slave_fd)
output_thread = threading.Thread(
target=_pump_task_pty,
kwargs={"master_fd": master_fd, "console": console, "capture": stderr_buf},
daemon=True,
name=f"task-terminal-{task.task_id}",
)
output_thread.start()
output_threads = [output_thread]
history_gen_when_watch_started = session.terminal.history_generation
def _watch() -> None:
terminated_by_watcher = False
timed_out = False
suggest_follow_up = False
outcome_headline = "command completed (exit 0)"
outcome_error_kind = ""
while proc.poll() is None:
if time.monotonic() - started_at > timeout_seconds:
timed_out = True
task.request_cancel()
terminate_child_process(proc)
terminated_by_watcher = True
break
if task.cancel_requested.is_set():
terminate_child_process(proc)
terminated_by_watcher = True
break
time.sleep(_SYNTHETIC_POLL_SECONDS)
try:
if timed_out:
outcome_headline = f"command timed out after {timeout_seconds} seconds"
outcome_error_kind = "timeout"
task.mark_failed(f"timed out after {timeout_seconds}s")
suggest_follow_up = kind is TaskKind.SYNTHETIC_TEST
return
if terminated_by_watcher and task.cancel_requested.is_set():
outcome_headline = "command cancelled"
task.mark_cancelled()
return
_join_task_output_streams(output_threads)
code = proc.returncode
if code == 0:
task.mark_completed()
else:
diag = _sr_resolve("read_diag", read_diag)(stderr_buf)
error_msg = f"exit code {code}" + (f": {diag}" if diag else "")
outcome_headline = f"command failed (exit {code})"
outcome_error_kind = "cli_exit_nonzero"
task.mark_failed(error_msg)
console.print(f"[{ERROR}]command failed (exit {code}):[/]")
suggest_follow_up = kind is TaskKind.SYNTHETIC_TEST
except Exception as exc: # noqa: BLE001
outcome_headline = f"command error: {exc}"
outcome_error_kind = "watcher_error"
task.mark_failed(str(exc))
report_exception(exc, context="surfaces.interactive_shell.background_cli_task.watch")
console.print(f"[{ERROR}]error:[/] {escape(str(exc))}")
suggest_follow_up = kind is TaskKind.SYNTHETIC_TEST
finally:
_join_task_output_streams(output_threads)
# Flush the prompt-log/PostHog event with the real outcome (stdout,
# stderr, exit/timeout/cancel) before the capture buffers are closed.
if recorder is not None:
with contextlib.suppress(Exception):
if outcome_error_kind:
recorder.set_error(outcome_error_kind, outcome_headline)
recorder.set_response(
_compose_task_log_response(
headline=outcome_headline,
stdout_buf=stdout_buf,
stderr_buf=stderr_buf,
)
)
recorder.flush()
if stdout_buf is not None:
stdout_buf.close()
stderr_buf.close()
if (
suggest_follow_up
and session.terminal.history_generation == history_gen_when_watch_started
):
session.suggest_synthetic_failure_follow_up(label=display_command)
else:
session.terminal.notify_prompt_changed()
thread = threading.Thread(target=_watch, daemon=True)
thread.start()
console.print(
f"[{DIM}]started — task[/] [bold]{escape(task.task_id)}[/bold]. "
f"[{HIGHLIGHT}]/tasks[/] [{DIM}]to monitor,[/] "
f"[{HIGHLIGHT}]/cancel {escape(task.task_id)}[/] [{DIM}]to stop.[/]"
)
return task
@@ -0,0 +1,151 @@
"""OpenSRE CLI command runner — surface adapter over tools.interactive_shell.cli."""
from __future__ import annotations
from collections.abc import Callable
from rich.console import Console
from surfaces.interactive_shell.runtime.subprocess_runner.repl_presenter import make_repl_presenter
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui import DIM, WARNING
from tools.interactive_shell.cli import (
INTERACTIVE_OPENSRE_COMMAND_PATHS,
OPENSRE_BLOCKED_SUBCOMMANDS,
OpensreCommandClass,
OpensreExecutionMode,
OpensreExecutionPlan,
OpensreRunOutcome,
OpensreRunResult,
_run_foreground_via_presenter,
_run_streaming_via_presenter,
build_opensre_cli_argv,
build_opensre_execution_plan,
classify_opensre_command,
interactive_wizard_handoff_response_text,
is_interactive_wizard,
opensre_confirmation_reason,
)
from tools.interactive_shell.cli import (
run_opensre_cli_command as _run_opensre_cli_command,
)
from tools.interactive_shell.cli import (
run_opensre_cli_command_result as _run_opensre_cli_command_result,
)
# Backward-compatible aliases for tests and slash parity.
_INTERACTIVE_OPENSRE_COMMAND_PATHS = INTERACTIVE_OPENSRE_COMMAND_PATHS
_OPENSRE_BLOCKED_SUBCOMMANDS = OPENSRE_BLOCKED_SUBCOMMANDS
def _is_interactive_wizard(tokens: list[str]) -> bool:
return is_interactive_wizard(tokens)
def _classify_opensre_command(tokens: list[str]) -> str:
return classify_opensre_command(tokens)
def _opensre_confirmation_reason(tokens: list[str]) -> str:
return opensre_confirmation_reason(tokens)
def _build_opensre_execution_plan(tokens: list[str]) -> OpensreExecutionPlan:
return build_opensre_execution_plan(tokens)
def print_interactive_wizard_handoff(console: Console, command_str: str) -> None:
console.print(
f"[{WARNING}]`opensre {command_str}` is an interactive wizard "
"that needs a full terminal.[/]"
)
console.print(
f"[{DIM}]Type [bold]/{command_str}[/bold] directly in this shell to launch it.[/]"
)
def run_opensre_cli_command(
args: str,
session: Session,
console: Console,
*,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
) -> bool:
presenter = make_repl_presenter(
session,
console,
confirm_fn=confirm_fn,
is_tty=is_tty,
action_already_listed=True,
)
return _run_opensre_cli_command(args, presenter)
def run_opensre_cli_command_result(
args: str,
session: Session,
console: Console,
*,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
) -> OpensreRunResult:
presenter = make_repl_presenter(
session,
console,
confirm_fn=confirm_fn,
is_tty=is_tty,
action_already_listed=True,
)
return _run_opensre_cli_command_result(args, presenter)
# Foreground helpers kept for monkeypatch tests that patch subprocess_runner paths.
def _run_opensre_foreground(
argv_list: list[str],
display_command: str,
session: Session,
console: Console,
) -> None:
presenter = make_repl_presenter(session, console, action_already_listed=True)
_run_foreground_via_presenter(
presenter,
argv_list=argv_list,
display_command=display_command,
)
def _run_opensre_foreground_streaming(
argv_list: list[str],
display_command: str,
session: Session,
console: Console,
) -> None:
presenter = make_repl_presenter(session, console, action_already_listed=True)
_run_streaming_via_presenter(
presenter,
argv_list=argv_list,
display_command=display_command,
)
__all__ = [
"OpensreCommandClass",
"OpensreExecutionMode",
"OpensreExecutionPlan",
"OpensreRunOutcome",
"OpensreRunResult",
"_INTERACTIVE_OPENSRE_COMMAND_PATHS",
"_OPENSRE_BLOCKED_SUBCOMMANDS",
"_build_opensre_execution_plan",
"_classify_opensre_command",
"_is_interactive_wizard",
"_opensre_confirmation_reason",
"_run_opensre_foreground",
"_run_opensre_foreground_streaming",
"build_opensre_cli_argv",
"interactive_wizard_handoff_response_text",
"print_interactive_wizard_handoff",
"run_opensre_cli_command",
"run_opensre_cli_command_result",
]
@@ -0,0 +1,226 @@
"""REPL subprocess presenter — Rich UI + session hooks for action tools."""
from __future__ import annotations
import re
import subprocess
import tempfile
import threading
from collections.abc import Callable
from typing import Any
from rich.console import Console
from rich.markup import escape
from rich.text import Text
from platform.common.task_types import TaskKind
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT, WARNING, print_command_output
from surfaces.interactive_shell.ui.execution_confirm import execution_allowed
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
from tools.interactive_shell.shared import ExecutionPolicyResult
from tools.interactive_shell.subprocess import SubprocessPresenter, subprocess_env_with_width
from .background_task_executor import (
start_background_cli_task as _start_background_cli_task_default,
)
from .task_streaming import (
_join_task_output_streams,
_sr_resolve,
_start_task_output_streams,
)
_MARKUP_STYLE_ALIASES: dict[str, str] = {
"error": str(ERROR),
"dim": str(DIM),
"highlight": str(HIGHLIGHT),
"warning": str(WARNING),
}
# Intentional Rich markup tags used by subprocess presenters and action tools.
_ALLOWED_MARKUP_TAG = re.compile(
r"(\["
r"(?:"
r"#[0-9A-Fa-f]{6}|"
r"bold|dim|highlight|warning|error|"
r"/(?:bold|dim|highlight|warning|error)?"
r")"
r"\])"
)
_MARKUP_HINT = re.compile(r"\[(?:/?(?:error|dim|highlight|warning|bold)|/)\]")
def _expand_markup_aliases(message: str) -> str:
for alias, token in _MARKUP_STYLE_ALIASES.items():
message = message.replace(f"[{alias}]", f"[{token}]")
message = message.replace(f"[/{alias}]", f"[/{token}]")
return message
def _message_uses_intentional_markup(message: str) -> bool:
if _MARKUP_HINT.search(message):
return True
return any(
f"[{token}]" in message or f"[/{token}]" in message
for token in _MARKUP_STYLE_ALIASES.values()
)
def _escape_markup_message(message: str) -> str:
"""Escape plain-text segments while preserving intentional Rich markup tags."""
expanded = _expand_markup_aliases(message)
if not _message_uses_intentional_markup(expanded):
return escape(expanded)
parts = _ALLOWED_MARKUP_TAG.split(expanded)
if len(parts) == 1:
return escape(expanded)
rendered: list[str] = []
for index, part in enumerate(parts):
if index % 2 == 1:
rendered.append(part)
elif part:
rendered.append(escape(part))
return "".join(rendered)
class ReplSubprocessPresenter:
"""Surface implementation of :class:`SubprocessPresenter` for the interactive REPL."""
def __init__(
self,
session: Session,
console: Console,
*,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
action_already_listed: bool = False,
) -> None:
self._session = session
self._console = console
self._confirm_fn = confirm_fn
self._is_tty = is_tty
self._action_already_listed = action_already_listed
@property
def session(self) -> Session:
return self._session
@property
def console(self) -> Console:
return self._console
def execution_allowed(
self,
policy: ExecutionPolicyResult,
*,
action_summary: str,
) -> bool:
return execution_allowed(
policy,
session=self._session,
console=self._console,
action_summary=action_summary,
confirm_fn=self._confirm_fn,
is_tty=self._is_tty,
action_already_listed=self._action_already_listed,
)
def print(self, message: str = "") -> None:
self._console.print(_escape_markup_message(message))
def print_bold_command(self, display_command: str) -> None:
self._console.print(f"[bold]$ {escape(display_command)}[/bold]")
def print_command_output(self, text: str, *, style: str | None = None) -> None:
resolved: str | None
if style in _MARKUP_STYLE_ALIASES:
resolved = _MARKUP_STYLE_ALIASES[style]
elif style is None:
resolved = None
else:
resolved = style
print_command_output(self._console, text, style=resolved)
def print_plain(self, text: str) -> None:
self._console.print(Text(text))
def report_exception(self, exc: BaseException, *, context: str) -> None:
report_exception(exc, context=context)
def subprocess_env(self) -> dict[str, str]:
return subprocess_env_with_width(
columns=self._console.size.width or 80,
lines=self._console.size.height,
)
def start_task_output_streams(
self,
*,
task: Any,
proc: subprocess.Popen[Any],
stdout_capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
stderr_capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
) -> list[threading.Thread]:
return _start_task_output_streams(
task=task,
proc=proc,
console=self._console,
stdout_capture=stdout_capture,
stderr_capture=stderr_capture,
)
def join_task_output_streams(self, threads: list[threading.Thread]) -> None:
_join_task_output_streams(threads)
def start_background_cli_task(
self,
*,
display_command: str,
argv_list: list[str],
timeout_seconds: int,
kind: TaskKind = TaskKind.CLI_COMMAND,
use_pty: bool = False,
) -> Any:
starter = _sr_resolve("start_background_cli_task", _start_background_cli_task_default)
return starter(
display_command=display_command,
argv_list=argv_list,
session=self._session,
console=self._console,
timeout_seconds=timeout_seconds,
kind=kind,
use_pty=use_pty,
)
def print_error(self, message: str) -> None:
self._console.print(f"[{ERROR}]{escape(message)}[/]")
def print_dim(self, message: str) -> None:
self._console.print(f"[{DIM}]{escape(message)}[/]")
def print_highlight(self, message: str) -> None:
self._console.print(f"[{HIGHLIGHT}]{escape(message)}[/]")
def print_warning(self, message: str) -> None:
self._console.print(f"[{WARNING}]{escape(message)}[/]")
def make_repl_presenter(
session: Session,
console: Console,
*,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
action_already_listed: bool = False,
) -> SubprocessPresenter:
"""Construct a :class:`ReplSubprocessPresenter` for runners and tests."""
return ReplSubprocessPresenter(
session,
console,
confirm_fn=confirm_fn,
is_tty=is_tty,
action_already_listed=action_already_listed,
)
__all__ = ["ReplSubprocessPresenter", "make_repl_presenter"]
@@ -0,0 +1,202 @@
"""Shared subprocess-streaming primitives, PTY helpers, and module-wide constants."""
from __future__ import annotations
import contextlib
import errno
import os
import subprocess
import sys
import tempfile
import threading
from typing import IO, Any
from rich.console import Console
from rich.markup import escape
from rich.text import Text
from platform.common.task_types import TaskRecord
from surfaces.interactive_shell.ui import DIM, ERROR
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
from tools.interactive_shell.subprocess import (
CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS,
MAX_COMMAND_OUTPUT_CHARS,
MIN_SUBPROCESS_TERMINAL_WIDTH,
SHELL_COMMAND_TIMEOUT_SECONDS,
SYNTHETIC_DIAG_CHARS,
SYNTHETIC_POLL_SECONDS,
SYNTHETIC_TEST_TIMEOUT_SECONDS,
TASK_OUTPUT_JOIN_TIMEOUT_SECONDS,
TASK_OUTPUT_PREFIX_WIDTH,
read_diag,
read_task_output,
subprocess_env_with_width,
terminate_child_process,
)
# Full dotted name of the ``subprocess_runner`` package. Submodules use this to
# look up patchable names from the parent namespace at call time so that tests
# using ``monkeypatch.setattr("…subprocess_runner.X", fake)`` take effect even
# when the implementation lives in a submodule.
_SUBPROCESS_RUNNER_MODULE = "surfaces.interactive_shell.runtime.subprocess_runner"
# Backward-compatible aliases for tests and callers using underscore-prefixed names.
_MAX_COMMAND_OUTPUT_CHARS = MAX_COMMAND_OUTPUT_CHARS
_SYNTHETIC_POLL_SECONDS = SYNTHETIC_POLL_SECONDS
_SYNTHETIC_DIAG_CHARS = SYNTHETIC_DIAG_CHARS
_MIN_SUBPROCESS_TERMINAL_WIDTH = MIN_SUBPROCESS_TERMINAL_WIDTH
_TASK_OUTPUT_PREFIX_WIDTH = TASK_OUTPUT_PREFIX_WIDTH
_TASK_OUTPUT_JOIN_TIMEOUT_SECONDS = TASK_OUTPUT_JOIN_TIMEOUT_SECONDS
def _sr_resolve(name: str, default: Any) -> Any:
"""Return ``subprocess_runner.<name>`` if the package is loaded, else ``default``.
Used by submodules to honour monkeypatches applied to the parent package
namespace (e.g. ``monkeypatch.setattr("…subprocess_runner.read_diag", …)``).
"""
sr = sys.modules.get(_SUBPROCESS_RUNNER_MODULE)
return getattr(sr, name, default) if sr is not None else default
def _print_task_output_line(
console: Console,
task: TaskRecord,
stream_name: str,
line: str,
*,
style: str | None = None,
) -> None:
text = Text()
text.append(f"{task.task_id} {stream_name}", style=DIM)
text.append(line.rstrip("\r\n"), style=style)
console.print(text)
def _subprocess_env_with_aligned_width(console: Console) -> dict[str, str]:
"""Return ``os.environ`` patched so a piped Rich subprocess wraps to fit."""
user_width = console.size.width or _MIN_SUBPROCESS_TERMINAL_WIDTH + _TASK_OUTPUT_PREFIX_WIDTH
return subprocess_env_with_width(
columns=user_width,
lines=console.size.height,
)
def _pump_task_stream(
*,
task: TaskRecord,
stream_name: str,
stream: IO[str],
console: Console,
style: str | None = None,
capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
) -> None:
try:
for line in stream:
if capture is not None:
capture.write(line.encode("utf-8", errors="replace"))
if line.strip():
_print_task_output_line(console, task, stream_name, line, style=style)
task.update_progress(line)
except Exception as exc: # noqa: BLE001
report_exception(exc, context=f"surfaces.interactive_shell.task_stream.{stream_name}")
console.print(f"[{DIM}]task output stream ended unexpectedly:[/] {escape(str(exc))}")
def _start_task_output_streams(
*,
task: TaskRecord,
proc: subprocess.Popen[Any],
console: Console,
stdout_capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
stderr_capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
) -> list[threading.Thread]:
threads: list[threading.Thread] = []
streams: tuple[tuple[str, IO[str] | None, str | None, Any], ...] = (
("stdout", proc.stdout, None, stdout_capture),
("stderr", proc.stderr, ERROR, stderr_capture),
)
for stream_name, stream, style, capture in streams:
if stream is None:
continue
thread = threading.Thread(
target=_pump_task_stream,
kwargs={
"task": task,
"stream_name": stream_name,
"stream": stream,
"console": console,
"style": style,
"capture": capture,
},
daemon=True,
name=f"task-output-{task.task_id}-{stream_name}",
)
thread.start()
threads.append(thread)
return threads
def _join_task_output_streams(threads: list[threading.Thread]) -> None:
for thread in threads:
thread.join(timeout=TASK_OUTPUT_JOIN_TIMEOUT_SECONDS)
def _console_file_is_tty(console: Console) -> bool:
isatty = getattr(console.file, "isatty", None)
return bool(isatty and isatty())
def _should_use_pty(console: Console, requested: bool) -> bool:
return requested and hasattr(os, "openpty") and _console_file_is_tty(console)
def _pump_task_pty(
*,
master_fd: int,
console: Console,
capture: tempfile.SpooledTemporaryFile[bytes], # type: ignore[type-arg]
) -> None:
try:
while True:
try:
chunk = os.read(master_fd, 4096)
except OSError as exc:
if exc.errno == errno.EIO:
break
raise
if not chunk:
break
capture.write(chunk)
console.file.write(chunk.decode("utf-8", errors="replace"))
console.file.flush()
except Exception as exc: # noqa: BLE001
report_exception(exc, context="surfaces.interactive_shell.task_pty_stream")
console.print(f"[{DIM}]task terminal stream ended unexpectedly:[/] {escape(str(exc))}")
finally:
with contextlib.suppress(OSError):
os.close(master_fd)
__all__ = [
"SHELL_COMMAND_TIMEOUT_SECONDS",
"SYNTHETIC_TEST_TIMEOUT_SECONDS",
"CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS",
"_SYNTHETIC_POLL_SECONDS",
"_MAX_COMMAND_OUTPUT_CHARS",
"_SYNTHETIC_DIAG_CHARS",
"_TASK_OUTPUT_PREFIX_WIDTH",
"_MIN_SUBPROCESS_TERMINAL_WIDTH",
"_TASK_OUTPUT_JOIN_TIMEOUT_SECONDS",
"terminate_child_process",
"read_diag",
"read_task_output",
"_print_task_output_line",
"_subprocess_env_with_aligned_width",
"_pump_task_stream",
"_start_task_output_streams",
"_join_task_output_streams",
"_console_file_is_tty",
"_should_use_pty",
"_pump_task_pty",
]