Skip to content

Commit 078c8c3

Browse files
adgdeigithub-actions[bot]haofeif
authored
fix(web): attach terminals through configured backend (#417)
* fix(web): attach terminals through configured backend Route web terminal attachment through the configured terminal backend, preserve tmux behavior, focus the requested Herdr tab before launching its TUI, and handle backend attachment failures safely. Squash-rebased onto awslabs/cli-agent-orchestrator main at 67f8e4b. * chore: add one-use PR #417 changelog workflow * docs: add PR #417 changelog entry * chore: add one-use PR #417 validation workflow * chore: complete PR #417 validation --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: haofeif <56006724+haofeif@users.noreply.github.com>
1 parent 3697de5 commit 078c8c3

8 files changed

Lines changed: 159 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- web: attach web terminals through the configured backend so herdr-backed terminals no longer fail to attach (#417)
1213
- honor profile frontmatter `provider:` during install (flag > frontmatter > default) (#414)
1314
### Security
1415

src/cli_agent_orchestrator/api/main.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from fastapi.responses import JSONResponse
3636
from pydantic import BaseModel, Field, field_validator, model_validator
3737

38-
from cli_agent_orchestrator.backends import TerminalNotFoundError
38+
from cli_agent_orchestrator.backends import TerminalBackendError, TerminalNotFoundError
3939
from cli_agent_orchestrator.backends.herdr_backend import HerdrBackend
4040
from cli_agent_orchestrator.backends.registry import get_backend
4141
from cli_agent_orchestrator.clients.database import (
@@ -2050,29 +2050,39 @@ async def terminal_ws(websocket: WebSocket, terminal_id: str):
20502050
# or future code paths could still bypass that, and tmux parses
20512051
# ':' / '.' as target delimiters. Bind the validator return values
20522052
# so the sanitization is explicit at the actual sink below.
2053+
# This tmux-shaped validation is deliberately applied to every backend.
20532054
try:
20542055
session_name = validate_tmux_name(metadata["tmux_session"], "session_name")
20552056
window_name = validate_tmux_name(metadata["tmux_window"], "window_name")
20562057
except ValueError:
20572058
await websocket.close(code=4003, reason="Invalid tmux target name")
20582059
return
20592060

2060-
# Create PTY pair for tmux attach
2061+
try:
2062+
attach_command = await asyncio.to_thread(
2063+
get_backend().prepare_web_attach, session_name, window_name
2064+
)
2065+
except TerminalBackendError as e:
2066+
logger.error(f"Web attach failed for terminal {terminal_id}: {e}")
2067+
await websocket.close(code=4004, reason="Failed to attach terminal")
2068+
return
2069+
2070+
# Create PTY pair for backend attach
20612071
master_fd, slave_fd = pty.openpty()
20622072

20632073
# Set initial terminal size
20642074
winsize = struct.pack("HHHH", 24, 80, 0, 0)
20652075
fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, winsize)
20662076

2067-
# Start tmux attach inside the PTY.
2077+
# Start the configured backend's interactive client inside the PTY.
20682078
# Container/devcontainer environments often leave TERM unset or set to
20692079
# ``dumb``, which strips colours, breaks cursor positioning and corrupts
20702080
# the Ink-based TUIs that agent CLIs render. Force a sane default so the
20712081
# browser-side xterm.js renderer sees the escape sequences it expects.
20722082
# Any explicit non-dumb TERM the operator set is preserved.
20732083
pty_env = _build_pty_env()
20742084
proc = subprocess.Popen(
2075-
["tmux", "-u", "attach-session", "-t", f"{session_name}:{window_name}"],
2085+
attach_command,
20762086
stdin=slave_fd,
20772087
stdout=slave_fd,
20782088
stderr=slave_fd,

src/cli_agent_orchestrator/backends/base.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,25 @@ def attach_session(self, session_name: str) -> None:
238238
"""
239239
...
240240

241+
@abstractmethod
242+
def prepare_web_attach(self, session_name: str, window_name: str) -> List[str]:
243+
"""Prepare a browser PTY attachment and return its subprocess argv.
244+
245+
Backends may perform routing work before returning, such as focusing a
246+
Herdr workspace/tab. The caller owns the PTY and subprocess lifecycle.
247+
248+
Args:
249+
session_name: Target session
250+
window_name: Target window
251+
252+
Returns:
253+
Subprocess argv for the interactive backend client
254+
255+
Raises:
256+
TerminalBackendError: If the backend cannot prepare the attachment
257+
"""
258+
...
259+
241260
# --- Pipe-pane (logging) ---
242261

243262
@abstractmethod

src/cli_agent_orchestrator/backends/herdr_backend.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,13 @@ def attach_session(self, session_name: str) -> None:
543543
# Equivalent to `tmux attach-session -t <session>`.
544544
os.execvp("herdr", ["herdr", "--session", self._herdr_session])
545545

546+
def prepare_web_attach(self, session_name: str, window_name: str) -> List[str]:
547+
"""Focus the requested Herdr tab and return the browser PTY attach command."""
548+
workspace_id = self._resolve_workspace_id(session_name)
549+
tab_id = self._resolve_tab_id(session_name, workspace_id, window_name)
550+
self._run_herdr(["tab", "focus", tab_id])
551+
return ["herdr", "--session", self._herdr_session]
552+
546553
# --- Capability overrides ---
547554

548555
def supports_event_inbox(self) -> bool:

src/cli_agent_orchestrator/backends/tmux_backend.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ def attach_session(self, session_name: str) -> None:
134134

135135
subprocess.run(["tmux", "attach-session", "-t", session_name], check=True)
136136

137+
def prepare_web_attach(self, session_name: str, window_name: str) -> List[str]:
138+
"""Return the tmux command used by the browser PTY WebSocket."""
139+
return ["tmux", "-u", "attach-session", "-t", f"{session_name}:{window_name}"]
140+
137141
# --- Pipe-pane ---
138142

139143
def pipe_pane(self, session_name: str, window_name: str, file_path: str) -> None:

test/api/test_terminals.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,13 +613,23 @@ def capture_and_stop(*args, **kwargs):
613613
captured["kwargs"] = kwargs
614614
raise _StopHere("captured Popen args")
615615

616+
backend = MagicMock()
617+
backend.prepare_web_attach.return_value = [
618+
"tmux",
619+
"-u",
620+
"attach-session",
621+
"-t",
622+
"cao-s:w",
623+
]
624+
616625
with (
617626
patch.dict("os.environ", {"TERM": "dumb", "HOME": "/root"}, clear=True),
618627
patch.object(
619628
main_module,
620629
"get_terminal_metadata",
621630
return_value={"tmux_session": "cao-s", "tmux_window": "w"},
622631
),
632+
patch.object(main_module, "get_backend", return_value=backend),
623633
patch.object(main_module.subprocess, "Popen", side_effect=capture_and_stop),
624634
patch.object(main_module.pty, "openpty", return_value=(100, 101)),
625635
patch.object(main_module.fcntl, "ioctl"),
@@ -636,6 +646,72 @@ def capture_and_stop(*args, **kwargs):
636646
)
637647
assert passed_env["TERM"] == "xterm-256color"
638648

649+
@pytest.mark.asyncio
650+
async def test_websocket_uses_configured_backend_attach_command(self):
651+
"""Herdr-backed terminals must not be attached through tmux."""
652+
from cli_agent_orchestrator.api import main as main_module
653+
654+
ws = MagicMock()
655+
ws.client = MagicMock(host="127.0.0.1")
656+
ws.accept = AsyncMock()
657+
ws.close = AsyncMock()
658+
659+
backend = MagicMock()
660+
backend.prepare_web_attach.return_value = ["herdr", "--session", "cao"]
661+
662+
captured: Dict[str, object] = {}
663+
664+
def capture_and_stop(*args, **kwargs):
665+
captured["args"] = args
666+
raise _StopHere("captured Popen args")
667+
668+
with (
669+
patch.object(
670+
main_module,
671+
"get_terminal_metadata",
672+
return_value={"tmux_session": "cao-s", "tmux_window": "w"},
673+
),
674+
patch.object(main_module, "get_backend", return_value=backend),
675+
patch.object(main_module.subprocess, "Popen", side_effect=capture_and_stop),
676+
patch.object(main_module.pty, "openpty", return_value=(100, 101)),
677+
patch.object(main_module.fcntl, "ioctl"),
678+
patch.object(main_module.fcntl, "fcntl"),
679+
patch.object(main_module.os, "close"),
680+
):
681+
with pytest.raises(_StopHere):
682+
await main_module.terminal_ws(ws, "abcd1234")
683+
684+
backend.prepare_web_attach.assert_called_once_with("cao-s", "w")
685+
assert captured["args"][0] == ["herdr", "--session", "cao"]
686+
687+
@pytest.mark.asyncio
688+
async def test_websocket_closes_safely_when_backend_attach_fails(self):
689+
"""Backend attach errors close safely before allocating a PTY."""
690+
from cli_agent_orchestrator.api import main as main_module
691+
from cli_agent_orchestrator.backends import TerminalBackendError
692+
693+
ws = MagicMock()
694+
ws.client = MagicMock(host="127.0.0.1")
695+
ws.accept = AsyncMock()
696+
ws.close = AsyncMock()
697+
698+
backend = MagicMock()
699+
backend.prepare_web_attach.side_effect = TerminalBackendError("sensitive backend detail")
700+
701+
with (
702+
patch.object(
703+
main_module,
704+
"get_terminal_metadata",
705+
return_value={"tmux_session": "cao-s", "tmux_window": "w"},
706+
),
707+
patch.object(main_module, "get_backend", return_value=backend),
708+
patch.object(main_module.pty, "openpty") as mock_openpty,
709+
):
710+
await main_module.terminal_ws(ws, "abcd1234")
711+
712+
ws.close.assert_awaited_once_with(code=4004, reason="Failed to attach terminal")
713+
mock_openpty.assert_not_called()
714+
639715

640716
class _StopHere(Exception):
641717
"""Sentinel raised by the wiring test once Popen args are captured."""

test/backends/test_herdr_backend.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,35 @@ def test_list_sessions(self, mock_run, backend):
115115
assert result[0]["name"] == "cao-proj1"
116116
assert result[1]["name"] == "cao-proj2"
117117

118+
@patch("subprocess.run")
119+
def test_prepare_web_attach_focuses_tab_and_returns_herdr_command(self, mock_run, backend):
120+
"""Browser attachment focuses the requested Herdr tab before opening its TUI."""
121+
ws = [{"label": "cao-test", "workspace_id": "w1"}]
122+
tabs = [{"tab_id": "tab-1", "workspace_id": "w1", "label": "developer-abcd"}]
123+
mock_run.side_effect = [
124+
_completed(_make_workspace_list_response(ws)),
125+
_completed(_make_tab_list_response(tabs)),
126+
_completed(),
127+
]
128+
129+
command = backend.prepare_web_attach("cao-test", "developer-abcd")
130+
131+
assert command == ["herdr", "--session", "cao"]
132+
assert mock_run.call_args_list[-1].args[0][-3:] == ["tab", "focus", "tab-1"]
133+
134+
def test_prepare_web_attach_propagates_tab_not_found(self, backend):
135+
"""Browser attachment propagates a missing requested Herdr tab."""
136+
error = TerminalNotFoundError("cao-test:missing-window")
137+
138+
with (
139+
patch.object(backend, "_resolve_workspace_id", return_value="w1"),
140+
patch.object(backend, "_resolve_tab_id", side_effect=error),
141+
pytest.raises(TerminalNotFoundError) as exc_info,
142+
):
143+
backend.prepare_web_attach("cao-test", "missing-window")
144+
145+
assert exc_info.value is error
146+
118147
@patch("subprocess.run")
119148
def test_create_session_calls_workspace_create(self, mock_run, backend):
120149
"""create_session should call herdr workspace create with --label and inject env."""

test/backends/test_tmux_backend.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ def test_list_sessions_delegates(self, backend, mock_client):
7474
assert len(result) == 1
7575
mock_client.list_sessions.assert_called_once()
7676

77+
def test_prepare_web_attach_returns_window_target(self, backend):
78+
assert backend.prepare_web_attach("cao-test", "developer-abcd") == [
79+
"tmux",
80+
"-u",
81+
"attach-session",
82+
"-t",
83+
"cao-test:developer-abcd",
84+
]
85+
7786
def test_kill_session_delegates(self, backend, mock_client):
7887
mock_client.kill_session.return_value = True
7988
assert backend.kill_session("cao-test") is True

0 commit comments

Comments
 (0)