Skip to content

Commit bf49903

Browse files
feat(session): wire PTYTransport into /session/ws (§1.5)
/api/session/ws now runs the new transport stack — no more agent_session_manager, no legacy {type:"start"} frame. The route binds the already-acquired active.ActiveSession to the WS and pumps transport events both ways under asyncio.TaskGroup + except*. Why: §1.5 of the agent-session-transport plan. Closes the wiring gap that left session/transport.py, session/transports/pty.py, and session/active.py fully implemented but unreachable from the web UI since PR-B-1..B-4. Route contract (authoritative for frontend §1.7): - Preconditions: active session must already be acquired. WS closes 1008 pre-accept when current() is None. - Origin guard: localhost/127.0.0.1 any port by default, extend via STUDYLOOP_ALLOWED_ORIGINS env var (plan Blocker B1). - Session-id match: ?study_session_id must equal active.study_session_id or close 1008 (pattern from terminal_proxy.py:134). - Inbound JSON: input/resize/stop control frames. - Outbound: binary for OutputBytes, text JSON for lifecycle events. - finally: always await active.release() — guarantees a clean singleton on disconnect. Scope cuts (deferred to §1.5b, separate PR): - POST /session/start still runs legacy tmux+ttyd flow. Re-pointing it at active.acquire(config, PTYTransport) is the remaining §1.5 work; user deferred because it needs Playwright-driven iteration. - Frontend WS feature flag (§1.9) becomes a browser concern rather than server concern — the WS route no longer serves ttyd at all; iframe path stays behind /terminal/ in terminal_proxy.py. Legacy test cleanup: 2 stale tests removed from test_web_live_session.py (test_live_session_websocket_streams_events_and_accepts_input and test_live_session_websocket_rejects_acp_until_handshake_is_implemented) — they exercised the now-replaced protocol. test_session_runtime.py's unit tests of the legacy manager still pass; session_runtime/ stays on disk until §1.5b also lands. Tests: 1980 passed, 145 deselected. +8 new in test_web_session_ws.py (origin guard, session-id mismatch, no-active-session rejection, Started/Output/Stopped pump, input/resize/stop inbound frames, release-on-disconnect). Ruff clean, format clean. Plan: docs/plans/2026-05-09-refactor-agent-session-transport-plan.md Amendment #5 appended with the full route contract for §1.7 frontend. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0b5a638 commit bf49903

4 files changed

Lines changed: 518 additions & 136 deletions

File tree

docs/plans/2026-05-09-refactor-agent-session-transport-plan.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,94 @@ earlier. Ruff clean, format clean.
120120
now because `test_session_runtime.py` + the production
121121
`/session/ws` route still depend on it.
122122

123+
## Amendment #5 (2026-05-10, session 3) — §1.5 FastAPI wiring landed
124+
125+
`/api/session/ws` now runs the new transport stack. The route no longer
126+
reads `app.state.agent_session_manager`; it binds the already-acquired
127+
`active.ActiveSession` to the WebSocket and pumps transport events both
128+
ways. Legacy `session_runtime/` stays on disk (its unit tests in
129+
`test_session_runtime.py` still run) but is no longer in the request
130+
path for the web UI.
131+
132+
**What landed**
133+
134+
| PR | Commit | What landed |
135+
|---|---|---|
136+
| PR-B-5 | (this session) | `web/routes/session.py``live_session_socket` rewritten. Pre-accept Origin guard (plan Blocker B1) + `?study_session_id=` match + `TaskGroup` + `except*` pumps + binary `OutputBytes` out, text JSON for lifecycle events. 8 new tests in `test_web_session_ws.py`. Two stale legacy-protocol tests removed from `test_web_live_session.py`. |
137+
138+
**Route contract (authoritative for frontend §1.7)**
139+
140+
- **Preconditions**: caller must have already obtained an active
141+
session (via `POST /api/session/start` — still driving the legacy
142+
path today; PTY-acquire from REST is §1.5b follow-up) and know its
143+
`study_session_id`. Without an active session, the WS closes 1008
144+
before `accept()`.
145+
- **URL**: `/api/session/ws?study_session_id=<id>`. If the query param
146+
does not match `active.current().study_session_id`, close 1008.
147+
- **Origin allow-list**: localhost/127.0.0.1 any port by default.
148+
Operators behind a reverse proxy can extend via the
149+
`STUDYLOOP_ALLOWED_ORIGINS` env var (comma-separated).
150+
- **Inbound frames** (JSON text):
151+
- `{"type": "input", "data": "<string>"}``transport.send_input(data.encode("utf-8"))`
152+
- `{"type": "resize", "cols": N, "rows": N}``transport.resize(cols, rows)`
153+
- `{"type": "stop"}``transport.cancel()` then return
154+
- Unknown types are silently dropped (no error channel).
155+
- **Outbound frames**:
156+
- `OutputBytes.data`**binary** frame, verbatim bytes
157+
- `Started``{"type": "started", "agent": <name>}`
158+
- `Stopped``{"type": "stopped", "returncode": N|null, "reason": <str>}` (terminal — pump stops)
159+
- `TransportError``{"type": "transport_error", "message": <str>}`
160+
- `AgentMessage``{"type": "agent_message", "kind": <str>, "payload": <obj>}`
161+
- **Shutdown**: `finally: await active.release()`. Always runs — closes the
162+
transport and clears `session_state.json` regardless of which pump
163+
raised. Guarantees a subsequent `acquire()` will not hit
164+
`SessionAlreadyActiveError` on a clean disconnect.
165+
166+
**Deliberate scope cuts for this PR**
167+
168+
- **`POST /api/session/start` not yet re-pointed at `active.acquire()`**
169+
— it still runs the legacy tmux+ttyd flow. Switching it requires
170+
deciding how/when to call `active.acquire(config, lambda: PTYTransport(...))`
171+
with a resolved `persona_file` + `build_launch_cmd` wiring. User
172+
deferred this to "needs a lot of Playwright testing and iteration"
173+
and marked it as the §1.5b follow-up. Today the route is usable via
174+
tests only; the production flow still needs the REST start hook.
175+
- **Feature flag `STUDYLOOP_TRANSPORT=ttyd` / `?transport=ttyd`**
176+
the WS route no longer serves ttyd at all; the iframe path is
177+
reached via `/terminal/` in `terminal_proxy.py`. The *WS-level*
178+
feature flag described in plan §1.9 becomes a frontend concern
179+
("load iframe vs open WS") rather than a server concern. Rewording
180+
§1.9 for this reality is part of §1.6–1.9 when the frontend lands.
181+
- **ACP branch removed** from the WS protocol — the legacy route had
182+
a `{type: "start", transport: "acp"}` stub that returned an error.
183+
The new route has no `start` frame; there's nothing to return an
184+
error from. ACP support will route through its own `ACPTransport`
185+
when Phase 2 lands.
186+
187+
**Test-suite state after Amendment #5**
188+
189+
1980 passed, 145 deselected, 0 failures. +8 new tests
190+
(`test_web_session_ws.py`): origin guard, session-id mismatch, no-
191+
active-session rejection, Started/Output/Stopped pump, input / resize /
192+
stop inbound frames, release-on-disconnect. −2 stale tests
193+
(`test_live_session_websocket_streams_events_and_accepts_input`,
194+
`test_live_session_websocket_rejects_acp_until_handshake_is_implemented`).
195+
Net +6 vs. the 1974 at Amendment #4. Ruff clean, format clean.
196+
197+
### Open follow-ups after Amendment #5
198+
199+
- **§1.5b REST→PTY bridge**: `POST /api/session/start` with
200+
`transport=pty` should call `active.acquire(config, PTYTransport)`
201+
(skip tmux/ttyd, do persona setup inline, return the `study_session_id`
202+
for the WS query param). Separate PR so frontend iteration can drive
203+
the shape via Playwright. Biggest remaining plan-§1.5 item.
204+
- **§1.6–1.9 xterm.js frontend** — now unblocked at the server side;
205+
browser can `new WebSocket("/api/session/ws?study_session_id=…")`
206+
and speak the contract above.
207+
- **Legacy `session_runtime/` removal** — viable once §1.5b also
208+
lands (the WS route no longer references it; only
209+
`test_session_runtime.py` does). Follow-up chore.
210+
123211
## Enhancement Summary (Deepened 2026-05-09)
124212

125213
Plan deepened with nine parallel research agents (framework docs, best

packages/studyloop/src/studyloop/web/routes/session.py

Lines changed: 154 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -576,79 +576,170 @@ def get_session_options() -> dict[str, list[dict]]:
576576
}
577577

578578

579+
_WS_CLOSE_POLICY = 1008 # RFC 6455 Policy Violation
580+
581+
582+
def _allowed_ws_origins() -> set[str]:
583+
"""Return the allow-list for Origin on the live session WS.
584+
585+
Localhost only by default — this is a single-user tool. Expand via
586+
``STUDYLOOP_ALLOWED_ORIGINS`` (comma-separated) if the operator fronts
587+
the app behind a reverse proxy. See plan Blocker B1 (CSRF via cross-
588+
origin WS upgrade; RFC 6455 has no same-origin enforcement and CORS
589+
does not apply to WS).
590+
"""
591+
import os
592+
593+
defaults = {
594+
"http://127.0.0.1:8788",
595+
"http://localhost:8788",
596+
"http://127.0.0.1",
597+
"http://localhost",
598+
}
599+
extra = os.environ.get("STUDYLOOP_ALLOWED_ORIGINS", "").strip()
600+
if extra:
601+
return defaults | {o.strip() for o in extra.split(",") if o.strip()}
602+
return defaults
603+
604+
605+
def _origin_allowed(origin: str) -> bool:
606+
if not origin:
607+
return False
608+
allowed = _allowed_ws_origins()
609+
if origin in allowed:
610+
return True
611+
# Allow any port on localhost / 127.0.0.1 (dev server port may vary).
612+
for prefix in ("http://127.0.0.1", "http://localhost"):
613+
if origin.startswith(prefix + ":") or origin == prefix:
614+
return True
615+
return False
616+
617+
579618
@router.websocket("/session/ws")
580619
async def live_session_socket(websocket: WebSocket) -> None:
581-
"""Bidirectional live agent session socket.
582-
583-
Client messages:
584-
- {"type": "start", "topic": "...", "energy": 5, "agent": "codex", "transport": "pty"}
585-
- {"type": "input", "text": "..."}
586-
- {"type": "stop"}
620+
"""Bidirectional live agent session socket (plan §1.5).
621+
622+
The route binds an already-acquired ``active.ActiveSession`` to the
623+
WS client. Sessions are acquired via ``POST /api/session/start``
624+
(which calls ``active.acquire(config, PTYTransport)``); the WS then
625+
streams transport events out and pumps control frames in.
626+
627+
Inbound JSON control frames:
628+
- ``{"type": "input", "data": "..."}`` → ``transport.send_input``
629+
- ``{"type": "resize", "cols": N, "rows": N}`` → ``transport.resize``
630+
- ``{"type": "stop"}`` → ``transport.cancel``
631+
632+
Outbound framing:
633+
- ``OutputBytes.data`` → **binary** frame (verbatim PTY bytes)
634+
- ``Started``/``Stopped``/``TransportError``/``AgentMessage`` → text
635+
JSON frames (``{"type": ..., ...}``)
636+
637+
Close codes:
638+
- 1008 (Policy Violation) if Origin is disallowed, if no session is
639+
active, or if ``?study_session_id`` does not match the active one.
640+
- 1000 normal close on ``stop`` frame or transport-emitted ``Stopped``.
587641
"""
588-
await websocket.accept()
589-
manager = websocket.app.state.agent_session_manager
590-
session_id: str | None = None
591-
event_task: asyncio.Task[None] | None = None
642+
from studyloop.session import active as session_active
643+
from studyloop.session.transport import (
644+
AgentMessage,
645+
OutputBytes,
646+
Started,
647+
Stopped,
648+
TransportError,
649+
)
592650

593-
async def forward_events(events) -> None: # type: ignore[no-untyped-def]
594-
async for event in events:
595-
await websocket.send_json(event.as_dict())
651+
# --- Pre-accept guards -----------------------------------------------
596652

597-
try:
598-
while True:
599-
message = await websocket.receive_json()
600-
message_type = message.get("type")
601-
if message_type == "start":
602-
requested_transport = str(message.get("transport") or "pty")
603-
if requested_transport == "acp":
604-
await websocket.send_json(
605-
{
606-
"type": "error",
607-
"data": {
608-
"message": (
609-
"ACP transport is scaffolded but not enabled yet. "
610-
"Use Browser terminal for Gemini or Kiro."
611-
)
612-
},
613-
}
614-
)
615-
continue
616-
if session_id is not None:
617-
await websocket.send_json(
618-
{"type": "error", "data": {"message": "Session already started"}}
619-
)
620-
continue
621-
session_id, events = await manager.start_session(
622-
topic=str(message.get("topic") or "Study Session"),
623-
energy=int(message.get("energy") or 5),
624-
agent=message.get("agent"),
625-
transport=requested_transport,
653+
# Origin check (plan Blocker B1). Must happen before accept() — the
654+
# handshake has not yet completed, so close-without-accept sends an
655+
# HTTP 403 response per Starlette semantics, which the client sees as
656+
# a failed upgrade. After accept() we can only send a WS close frame.
657+
origin = websocket.headers.get("origin", "")
658+
if not _origin_allowed(origin):
659+
logger.warning("WS /session/ws rejected: disallowed origin=%r", origin)
660+
await websocket.close(code=_WS_CLOSE_POLICY)
661+
return
662+
663+
requested = websocket.query_params.get("study_session_id")
664+
current = await session_active.current()
665+
if current is None:
666+
await websocket.close(code=_WS_CLOSE_POLICY)
667+
return
668+
if requested and requested != current.study_session_id:
669+
logger.warning(
670+
"WS /session/ws rejected: requested=%r active=%r",
671+
requested,
672+
current.study_session_id,
673+
)
674+
await websocket.close(code=_WS_CLOSE_POLICY)
675+
return
676+
677+
await websocket.accept()
678+
transport = current.transport
679+
680+
async def pty_to_ws() -> None:
681+
"""Pump transport events → WS frames until the session ends."""
682+
async for event in transport.events():
683+
if isinstance(event, OutputBytes):
684+
await websocket.send_bytes(event.data)
685+
elif isinstance(event, Started):
686+
await websocket.send_json({"type": "started", "agent": event.agent})
687+
elif isinstance(event, Stopped):
688+
await websocket.send_json(
689+
{
690+
"type": "stopped",
691+
"returncode": event.returncode,
692+
"reason": event.reason,
693+
}
626694
)
627-
event_task = asyncio.create_task(forward_events(events))
628-
elif message_type == "input":
629-
if session_id is None:
630-
await websocket.send_json(
631-
{"type": "error", "data": {"message": "No active session"}}
632-
)
633-
continue
634-
await manager.send(session_id, str(message.get("text") or ""))
635-
elif message_type == "stop":
636-
if session_id:
637-
await manager.stop(session_id)
638-
session_id = None
639-
await websocket.close()
640-
return
641-
else:
695+
return # Stopped is terminal — stop pumping.
696+
elif isinstance(event, TransportError):
697+
await websocket.send_json({"type": "transport_error", "message": event.message})
698+
elif isinstance(event, AgentMessage):
642699
await websocket.send_json(
643-
{"type": "error", "data": {"message": "Unknown message type"}}
700+
{"type": "agent_message", "kind": event.kind, "payload": event.payload}
644701
)
645-
except WebSocketDisconnect:
702+
703+
async def ws_to_pty() -> None:
704+
"""Read WS control frames and forward to transport."""
705+
while True:
706+
frame = await websocket.receive_json()
707+
ftype = frame.get("type")
708+
if ftype == "input":
709+
data = frame.get("data", "")
710+
if isinstance(data, str):
711+
await transport.send_input(data.encode("utf-8"))
712+
elif ftype == "resize":
713+
try:
714+
cols = int(frame.get("cols", 80))
715+
rows = int(frame.get("rows", 24))
716+
except (TypeError, ValueError):
717+
continue
718+
await transport.resize(cols, rows)
719+
elif ftype == "stop":
720+
await transport.cancel()
721+
return
722+
# Silently drop unknown frame types — no error channel needed.
723+
724+
# --- Pump with TaskGroup (plan Blocker B5) ---------------------------
725+
#
726+
# TaskGroup raises ExceptionGroup on any child exception; the WS
727+
# disconnect paths come up as ExceptionGroup[WebSocketDisconnect,
728+
# ConnectionClosedOK, ...]. ``except*`` unpacks cleanly without
729+
# reaching for .exceptions.
730+
731+
try:
732+
async with asyncio.TaskGroup() as tg:
733+
tg.create_task(pty_to_ws(), name="ws-pty-to-ws")
734+
tg.create_task(ws_to_pty(), name="ws-ws-to-pty")
735+
except* WebSocketDisconnect:
646736
pass
737+
except* OSError as eg:
738+
logger.error("PTY I/O error on /session/ws: %s", eg.exceptions)
739+
except* Exception as eg: # pragma: no cover — defensive
740+
logger.exception("unexpected error on /session/ws: %s", eg.exceptions)
647741
finally:
648-
if event_task:
649-
event_task.cancel()
650-
if session_id:
651-
await manager.stop(session_id)
742+
await session_active.release()
652743

653744

654745
@router.post("/session/end")

0 commit comments

Comments
 (0)