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
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:
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user