Skip to content

feat(web-ui): AgentTerminal — XTerm.js interactive terminal for session workspace (#506)#516

Merged
frankbria merged 7 commits into
mainfrom
feature/issue-506-agent-terminal-xterm
Apr 1, 2026
Merged

feat(web-ui): AgentTerminal — XTerm.js interactive terminal for session workspace (#506)#516
frankbria merged 7 commits into
mainfrom
feature/issue-506-agent-terminal-xterm

Conversation

@frankbria

@frankbria frankbria commented Apr 1, 2026

Copy link
Copy Markdown
Owner

Summary

  • Backend: New terminal_ws.py router — WS /ws/sessions/{id}/terminal?token=<JWT> spawns bash in the session's workspace_path and relays stdio over WebSocket; auth pattern mirrors session_chat_ws.py
  • Frontend hook: useTerminalSocket — WebSocket lifecycle management with binary/text relay, exponential-backoff reconnect (max 3), sendInput/sendResize helpers; modeled after useEventSource
  • Frontend component: AgentTerminal — XTerm.js with FitAddon, dark theme (#0a0a0c bg, #a855f7 cursor), ResizeObserver for auto-fit, ReconnectingOverlay on disconnect

Notes

  • Uses asyncio.create_subprocess_exec with pipes (no PTY). Arrow keys and colour in interactive TUI apps (vim, htop) require a PTY — noted as known limitation.
  • Token retrieved from localStorage['auth_token'] (same as useAgentChat)
  • WS base URL from NEXT_PUBLIC_WS_URL env var (same as useAgentChat)

Test plan

  • 7 pytest tests: auth rejection (missing/invalid token, ended session, ownership mismatch), relay accepts, resize message doesn't crash
  • 9 Jest tests for useTerminalSocket: idle/connecting/open states, binary frames, text frames, sendInput, sendResize, reconnect backoff, error state after maxRetries, cleanup on unmount
  • uv run ruff check — no new lint errors
  • ESLint on new files — clean

Closes #506

Summary by CodeRabbit

  • New Features

    • Interactive per-session terminal: backend WebSocket endpoint, AgentTerminal UI, and a client hook to manage connection, send input and resize.
    • Terminal auto-fits its container and shows reconnecting / connection-failed overlays.
  • Tests

    • New unit/integration tests covering terminal WebSocket auth, relay behavior, reconnection, and resize flows.
  • Chores

    • Updated axios and added xterm + xterm addon dependencies.

… workspace (#506)

- Backend: add `/ws/sessions/{id}/terminal` WebSocket endpoint in `terminal_ws.py`
  that spawns bash in the session's workspace_path and relays stdio
- Register terminal_ws router in server.py
- Frontend: `useTerminalSocket` hook — WebSocket lifecycle, binary/text relay,
  exponential-backoff reconnect, sendInput/sendResize helpers
- Frontend: `AgentTerminal` component — XTerm.js with FitAddon, dark theme
  (#0a0a0c bg, #a855f7 cursor), ResizeObserver, ReconnectingOverlay
- Tests: 7 pytest tests (auth, relay, cleanup) + 9 Jest tests for hook
@coderabbitai

coderabbitai Bot commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a new backend WebSocket terminal endpoint that authenticates via JWT, validates session and user, spawns a shell subprocess in the session workspace and relays stdin/stdout; adds a client XTerm-based AgentTerminal component with a reconnecting useTerminalSocket hook, tests, and package dependency updates.

Changes

Cohort / File(s) Summary
Backend Terminal Router
codeframe/ui/routers/terminal_ws.py
New APIRouter and session_terminal_ws WS handler: JWT auth, user/session validation, per-user concurrent limit, spawn shell subprocess (no PTY) with env/cwd, relay stdin↔stdout, ignore resize, manage subprocess lifecycle and close codes.
Server Registration
codeframe/ui/server.py
Mounted terminal_ws.router so /ws/sessions/{id}/terminal is exposed.
Backend Tests
tests/ui/test_terminal_ws.py
New tests covering auth failures, session-not-found/ended, ownership checks, and basic relay flows using patched auth and a mocked subprocess.
Frontend Terminal Component
web-ui/src/components/sessions/AgentTerminal.tsx, web-ui/src/components/sessions/index.ts
Added AgentTerminal component (props: sessionId, className?): lazy-imports xterm + fit addon, mounts terminal, wires keystrokes → WS and WS → terminal, observes resizes and sends {type:"resize"}, shows reconnect overlay, disposes on unmount; re-exported.
WebSocket Hook & Exports
web-ui/src/hooks/useTerminalSocket.ts, web-ui/src/hooks/index.ts
New useTerminalSocket hook with status, binary/text handling, backoff reconnect, sendInput and sendResize; re-exported types via hooks index.
Frontend Tests
web-ui/src/__tests__/hooks/useTerminalSocket.test.ts
Jest tests for hook lifecycle, frame handling, send helpers, reconnection/backoff, auth-close handling, and cleanup using a MockWebSocket and fake timers.
Dependencies
web-ui/package.json
Bumped axios and added runtime deps @xterm/xterm and @xterm/addon-fit.
Misc frontend & tests
web-ui/src/..., web-ui/e2e/..., web-ui/tailwind.config.ts
Multiple small import/test cleanups, Tailwind plugin import switch, minor component/test tweaks and export re-exports unrelated to core terminal logic.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Client as Web Client (React)
    participant WS as FastAPI WS Endpoint
    participant DB as Database
    participant Shell as Bash Subprocess

    User->>Client: Open AgentTerminal
    Client->>WS: WS connect /ws/sessions/{id}/terminal?token=JWT
    WS->>DB: Validate JWT & load user + session
    DB-->>WS: user & session record
    alt auth & ownership valid
        WS->>Shell: spawn bash in workspace_path (cwd, env)
        WS-->>Client: websocket.accept()
        loop I/O relay
            User->>Client: keystroke
            Client->>WS: frame (text/binary)
            WS->>Shell: write stdin
            Shell-->>WS: stdout bytes
            WS-->>Client: relay bytes
            Client->>User: terminal.write()
        end
        Client->>WS: {"type":"resize",cols,rows}
        WS-->>WS: resize ignored (no PTY)
    else auth fails
        WS-->>Client: websocket.close(code, reason)
    end
    User->>Client: close/unmount
    Client->>WS: close()
    WS->>Shell: terminate/kill subprocess
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hopped into code and found a shell,

Keys tapped like raindrops, bytes rose and fell,
Bash in a tiny workspace, prompt aglow,
XTerm wrote stories in purple shadow,
Reconnect hums softly — the terminal says go.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.16% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title clearly summarizes the main change: adding an XTerm.js-based interactive terminal component (AgentTerminal) for the web-ui with backend WebSocket support.
Linked Issues check ✅ Passed PR fulfills all primary objectives from #506: backend WebSocket terminal endpoint with JWT auth, session/workspace validation, subprocess relay, resize handling, cleanup; frontend AgentTerminal component with XTerm.js, useTerminalSocket hook managing connection/reconnect/auth codes, dark theme, ResizeObserver, proper disposal.
Out of Scope Changes check ✅ Passed Minor cleanup changes (unused imports, test mock reorganization, Tailwind config migration) are reasonable maintenance adjustments; they don't distract from the core feature and improve code quality without introducing unrelated functionality.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/issue-506-agent-terminal-xterm

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

Review comment for AgentTerminal + terminal_ws.py - test post

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

Review: AgentTerminal + terminal_ws.py - Please see the full review below. (This is a placeholder — the full formatted review will follow as a separate comment due to sandbox formatting constraints.)

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

CRITICAL - Server environment leaked to subprocess: In terminal_ws.py, os.environ.copy() passes the full server environment including ANTHROPIC_API_KEY, DATABASE_PATH, CODEFRAME_API_KEY_SECRET into the user-controlled bash session. An authenticated user can run env or printenv to exfiltrate all credentials. Fix: allowlist only TERM, HOME, PATH, LANG, USER.

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

BUG - _text_stdin_relay is dead code: _text_stdin_relay is defined inside the handler but never scheduled as a task. Only _stdin_relay (binary) is launched via asyncio.create_task. Meanwhile, _stdin_relay catches all non-WebSocketDisconnect exceptions from receive_bytes() and silently breaks — which is exactly what Starlette does when a text frame arrives on a binary receive call. Text frames from clients are silently dropped. Since the frontend hook sends resize as JSON text, resize messages never reach the server. Fix: launch both relay coroutines as tasks and await all three, or unify into a single loop using websocket.receive() dispatching on event type.

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

SECURITY - JWT in URL query parameter: The token in the WS URL query param ends up in server logs, browser history, and reverse-proxy logs. This matches the existing session_chat_ws.py pattern, so worth tracking as a follow-up. Standard mitigations: short-lived one-time token via REST before connecting, or auth as the first WS message after handshake.

REACT - mutation during render: In AgentTerminal.tsx, wsUrlRef.current = buildWsUrl(sessionId) runs during render. This is fragile under React 18 strict mode (double-invokes renders). Move it into a useMemo or useEffect/useLayoutEffect.

What is working well:

  • Hook design mirrors useEventSource cleanly; exponential backoff, cleanup, and status transitions are all correct.
  • 7 backend auth tests + 9 Jest hook tests cover the important paths. The mock WebSocket implementation is solid.
  • Cleanup sequence (terminate + 3s wait + kill on timeout) is the right pattern.
  • Dynamic import keeps xterm out of the SSR bundle.
  • convertEol: true is the right default for piped (non-PTY) output.

Minor: workspace_path falls back to the FastAPI server CWD when unset — consider rejecting sessions missing it. The close useCallback in useTerminalSocket omits maxRetries/retryDelay from deps (intentional, but a brief comment would help).

Summary: Architecture and test coverage are in good shape. The two blockers are the env-var leak (trivial fix, high severity) and the dead text-relay task (explains why resize likely does not work end-to-end). Both are straightforward fixes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (5)
codeframe/ui/routers/terminal_ws.py (1)

100-103: Consider handling paused session state explicitly.

Per the schema in codeframe/persistence/schema_manager.py, valid states are active, paused, and ended. Currently, only ended is blocked. Consider whether terminal access should also be blocked for paused sessions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/ui/routers/terminal_ws.py` around lines 100 - 103, The session
check currently only blocks "ended" sessions; update the conditional in the
terminal WebSocket connection handling (where session is fetched via
db.interactive_sessions.get using session_id) to also treat "paused" as
disallowed—i.e., change the if to verify session is not None and that
session.get("state") is not in ("ended", "paused") or equivalently check if
session.get("state") in ("ended", "paused") then await websocket.close(... ) and
return; ensure you reference the same websocket.close call and session/state
keys so paused sessions are blocked like ended ones.
web-ui/src/components/sessions/AgentTerminal.tsx (2)

38-51: Consider using Hugeicons instead of inline SVG.

Per coding guidelines, the web UI should use @hugeicons/react rather than custom SVG icons. Check if Hugeicons provides a suitable loading spinner icon.

As per coding guidelines: "Web UI must use Shadcn/UI (Nova preset) with gray color scheme and Hugeicons (@hugeicons/react); never use lucide-react"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/src/components/sessions/AgentTerminal.tsx` around lines 38 - 51,
Replace the inline SVG spinner in AgentTerminal (the <svg ...> block rendering
the loading spinner) with the appropriate Hugeicons component from
`@hugeicons/react` (e.g., the library's spinner/loading icon), import that icon at
the top of the file and use it where the current svg is rendered, apply the
component the same sizing/animation classes but switch to the gray color scheme
(e.g., "animate-spin h-5 w-5 text-gray-400") to follow the Shadcn/UI Nova
preset, and remove the inline SVG so no custom SVG or lucide-react icon remains.

77-80: Setting ref during render is unconventional.

While this works for the initial mount, it's a side effect during render. Consider moving this initialization to a useMemo or early in the useEffect for clarity.

♻️ Alternative using useMemo
+import { useEffect, useRef, useMemo } from 'react';
 
-  const wsUrlRef = useRef<string | null>(null);
-
-  // Build the WS URL once per sessionId (requires client side)
-  if (typeof window !== 'undefined' && !wsUrlRef.current) {
-    wsUrlRef.current = buildWsUrl(sessionId);
-  }
+  const wsUrl = useMemo(() => {
+    if (typeof window === 'undefined') return null;
+    return buildWsUrl(sessionId);
+  }, [sessionId]);

   const { status, sendInput, sendResize } = useTerminalSocket({
-    url: wsUrlRef.current,
+    url: wsUrl,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/src/components/sessions/AgentTerminal.tsx` around lines 77 - 80, In
AgentTerminal, setting wsUrlRef.current during render is a side effect; move the
initialization out of render by computing the value with useMemo (e.g., const
wsUrl = useMemo(() => buildWsUrl(sessionId), [sessionId]) and assign to
wsUrlRef.current inside a useEffect) or by assigning wsUrlRef.current at the
start of a useEffect that depends on sessionId; update references that currently
read wsUrlRef.current to use the memoized wsUrl or ensure the ref is set in the
effect to avoid performing side effects during render.
web-ui/src/hooks/useTerminalSocket.ts (2)

83-89: Consider caching the TextEncoder instance.

A new TextEncoder is instantiated on every string message. Since TextEncoder is stateless, it can be created once at module scope or as a constant outside the hook.

♻️ Proposed optimization
 import { useEffect, useRef, useCallback, useState } from 'react';
 
+const textEncoder = new TextEncoder();
+
 export type TerminalSocketStatus = 'idle' | 'connecting' | 'open' | 'closed' | 'error';

Then update the usage:

       } else if (typeof event.data === 'string') {
-        onDataRef.current(new TextEncoder().encode(event.data));
+        onDataRef.current(textEncoder.encode(event.data));
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/src/hooks/useTerminalSocket.ts` around lines 83 - 89, Cache a single
TextEncoder instance instead of instantiating a new one for every string
message: create one TextEncoder at module scope or as a const outside the hook
and use it inside the ws.onmessage handler (the current block referencing
ws.onmessage, event.data, and onDataRef.current). Replace new
TextEncoder().encode(event.data) with the cached encoder.encode(event.data) so
string messages reuse the same encoder.

5-24: Consider moving exported types to the central types file.

The exported types TerminalSocketStatus, UseTerminalSocketOptions, and UseTerminalSocketReturn are defined inline. Per project guidelines, TypeScript types should be defined in web-ui/src/types/index.ts and re-imported here.

As per coding guidelines: "TypeScript types must be defined in web-ui/src/types/index.ts"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/src/hooks/useTerminalSocket.ts` around lines 5 - 24, Move the exported
type declarations TerminalSocketStatus, UseTerminalSocketOptions, and
UseTerminalSocketReturn out of this file and into the central types barrel
(web-ui/src/types/index.ts), then import and re-export or use them here;
specifically, create/type-add these three symbols in the central types file with
the same definitions, update this module to remove the inline definitions and
import { TerminalSocketStatus, UseTerminalSocketOptions, UseTerminalSocketReturn
} from the central types entry, and ensure any existing exports or usages
(sendInput, sendResize, status) still reference the imported types.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@codeframe/ui/routers/terminal_ws.py`:
- Around line 186-213: The _text_stdin_relay coroutine is never started, so text
frames sent by the frontend (via websocket.receive_text()) are ignored and cause
_stdin_relay (which uses websocket.receive_bytes()) to fail; create and start an
asyncio.Task named text_stdin_task for _text_stdin_relay (similar to how
_stdin_relay is started), ensure it runs alongside the existing stdin_relay
task, and update the cleanup/shutdown logic to cancel and await text_stdin_task
(handle asyncio.CancelledError) just like the other relay tasks to avoid
dangling tasks or exceptions.

In `@tests/ui/test_terminal_ws.py`:
- Line 7: Remove the unused import statement "import asyncio" from the test
module (the top-level import in the test file) so the ruff lint check no longer
fails; locate the line with the literal token import asyncio, delete it, and run
the test/lint to confirm the pipeline passes.

In `@web-ui/src/__tests__/hooks/useTerminalSocket.test.ts`:
- Around line 98-109: The test fails because it compares two different
Uint8Array instances by reference; update the assertion in the useTerminalSocket
test so it checks values not identity: when simulating binary frames with
MockWebSocket.simulateBinaryMessage(bytes) expect the onData callback (passed to
useTerminalSocket) to receive a Uint8Array with matching byte contents rather
than the same object reference — e.g., use a matcher like expect.any(Uint8Array)
for the call and add a separate assertion that compares contents (e.g., convert
to Array or compare with a new Uint8Array of the expected bytes) or assert
calledWith a new Uint8Array containing the same bytes; reference
functions/objects: useTerminalSocket, onData,
MockWebSocket.simulateBinaryMessage, and the test variable bytes.

In `@web-ui/src/components/sessions/AgentTerminal.tsx`:
- Around line 10-24: The buildWsUrl function currently inserts the raw token
into the query string which can break the URL if the JWT contains characters
like + or /; update buildWsUrl to URL-encode the token (use encodeURIComponent
on the value returned by getToken()) before interpolating it into the returned
string, preserving the existing null handling (i.e., still return null when
getToken() is falsy) and keeping the same base URL construction logic.

---

Nitpick comments:
In `@codeframe/ui/routers/terminal_ws.py`:
- Around line 100-103: The session check currently only blocks "ended" sessions;
update the conditional in the terminal WebSocket connection handling (where
session is fetched via db.interactive_sessions.get using session_id) to also
treat "paused" as disallowed—i.e., change the if to verify session is not None
and that session.get("state") is not in ("ended", "paused") or equivalently
check if session.get("state") in ("ended", "paused") then await
websocket.close(... ) and return; ensure you reference the same websocket.close
call and session/state keys so paused sessions are blocked like ended ones.

In `@web-ui/src/components/sessions/AgentTerminal.tsx`:
- Around line 38-51: Replace the inline SVG spinner in AgentTerminal (the <svg
...> block rendering the loading spinner) with the appropriate Hugeicons
component from `@hugeicons/react` (e.g., the library's spinner/loading icon),
import that icon at the top of the file and use it where the current svg is
rendered, apply the component the same sizing/animation classes but switch to
the gray color scheme (e.g., "animate-spin h-5 w-5 text-gray-400") to follow the
Shadcn/UI Nova preset, and remove the inline SVG so no custom SVG or
lucide-react icon remains.
- Around line 77-80: In AgentTerminal, setting wsUrlRef.current during render is
a side effect; move the initialization out of render by computing the value with
useMemo (e.g., const wsUrl = useMemo(() => buildWsUrl(sessionId), [sessionId])
and assign to wsUrlRef.current inside a useEffect) or by assigning
wsUrlRef.current at the start of a useEffect that depends on sessionId; update
references that currently read wsUrlRef.current to use the memoized wsUrl or
ensure the ref is set in the effect to avoid performing side effects during
render.

In `@web-ui/src/hooks/useTerminalSocket.ts`:
- Around line 83-89: Cache a single TextEncoder instance instead of
instantiating a new one for every string message: create one TextEncoder at
module scope or as a const outside the hook and use it inside the ws.onmessage
handler (the current block referencing ws.onmessage, event.data, and
onDataRef.current). Replace new TextEncoder().encode(event.data) with the cached
encoder.encode(event.data) so string messages reuse the same encoder.
- Around line 5-24: Move the exported type declarations TerminalSocketStatus,
UseTerminalSocketOptions, and UseTerminalSocketReturn out of this file and into
the central types barrel (web-ui/src/types/index.ts), then import and re-export
or use them here; specifically, create/type-add these three symbols in the
central types file with the same definitions, update this module to remove the
inline definitions and import { TerminalSocketStatus, UseTerminalSocketOptions,
UseTerminalSocketReturn } from the central types entry, and ensure any existing
exports or usages (sendInput, sendResize, status) still reference the imported
types.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 299959bf-3a54-4310-aaa4-2320cf927fa1

📥 Commits

Reviewing files that changed from the base of the PR and between 199f5fb and 6fbacba.

⛔ Files ignored due to path filters (1)
  • web-ui/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (9)
  • codeframe/ui/routers/terminal_ws.py
  • codeframe/ui/server.py
  • tests/ui/test_terminal_ws.py
  • web-ui/package.json
  • web-ui/src/__tests__/hooks/useTerminalSocket.test.ts
  • web-ui/src/components/sessions/AgentTerminal.tsx
  • web-ui/src/components/sessions/index.ts
  • web-ui/src/hooks/index.ts
  • web-ui/src/hooks/useTerminalSocket.ts

Comment thread codeframe/ui/routers/terminal_ws.py Outdated
Comment on lines +186 to +213
async def _text_stdin_relay() -> None:
assert process is not None
assert process.stdin is not None
try:
while True:
try:
raw = await websocket.receive_text()
except WebSocketDisconnect:
raise
except Exception:
break

try:
msg = json.loads(raw)
if isinstance(msg, dict) and msg.get("type") == "resize":
continue
process.stdin.write(raw.encode())
await process.stdin.drain()
except (json.JSONDecodeError,):
process.stdin.write(raw.encode())
await process.stdin.drain()

except WebSocketDisconnect:
raise
except asyncio.CancelledError:
pass
except Exception as exc:
logger.debug("Terminal text relay error: %s", exc)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how the frontend sends terminal input
rg -n 'sendInput|send\(' web-ui/src/hooks/useTerminalSocket.ts

Repository: frankbria/codeframe

Length of output: 382


🏁 Script executed:

cat -n codeframe/ui/routers/terminal_ws.py | sed -n '180,225p'

Repository: frankbria/codeframe

Length of output: 2169


🏁 Script executed:

cat -n codeframe/ui/routers/terminal_ws.py | sed -n '150,185p'

Repository: frankbria/codeframe

Length of output: 1764


Add _text_stdin_relay task to handle text frames from the frontend.

The function _text_stdin_relay() is defined but never invoked. Meanwhile, _stdin_relay() only calls websocket.receive_bytes() and breaks on any exception (line 163), which occurs when the client sends text frames. Since the frontend sends text frames via wsRef.current.send(data), this function must be added as a task for terminal input to work.

Fix: Add task for text relay
         stdout_to_ws_task = asyncio.create_task(_stdout_relay())
         ws_to_stdin_task = asyncio.create_task(_stdin_relay())
+        text_stdin_task = asyncio.create_task(_text_stdin_relay())

         # Wait for either task to finish (disconnect or process exit)
         await asyncio.wait(
-            [stdout_to_ws_task, ws_to_stdin_task],
+            [stdout_to_ws_task, ws_to_stdin_task, text_stdin_task],
             return_when=asyncio.FIRST_COMPLETED,
         )

Also update cleanup logic to handle text_stdin_task.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/ui/routers/terminal_ws.py` around lines 186 - 213, The
_text_stdin_relay coroutine is never started, so text frames sent by the
frontend (via websocket.receive_text()) are ignored and cause _stdin_relay
(which uses websocket.receive_bytes()) to fail; create and start an asyncio.Task
named text_stdin_task for _text_stdin_relay (similar to how _stdin_relay is
started), ensure it runs alongside the existing stdin_relay task, and update the
cleanup/shutdown logic to cancel and await text_stdin_task (handle
asyncio.CancelledError) just like the other relay tasks to avoid dangling tasks
or exceptions.

Comment thread tests/ui/test_terminal_ws.py Outdated
Comment thread web-ui/src/__tests__/hooks/useTerminalSocket.test.ts
Comment thread web-ui/src/components/sessions/AgentTerminal.tsx
@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

placeholder

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

Follow-up Review

Two commits since opening — the test fix (static imports) is good. The three issues from prior reviews are still unaddressed in the current code; flagging status and adding one new finding.

Still Open from Prior Reviews

[CRITICAL] os.environ.copy() leaks server credentials (terminal_ws.py line 112)
The full server environment including ANTHROPIC_API_KEY, CODEFRAME_API_KEY_SECRET, and DATABASE_PATH is passed into the user-controlled bash session. Any authenticated user can run env/printenv to exfiltrate all credentials. Needs to be fixed before merge by constructing a minimal allowlist env dict (PATH, HOME, USER, TERM, LANG).

[BUG] _text_stdin_relay is dead code (terminal_ws.py line 152)
The function is defined but never scheduled as a task — only _stdin_relay (binary frames) is launched via asyncio.create_task. Because _stdin_relay calls receive_bytes(), any text frame from the client raises a non-disconnect exception and silently breaks the loop, terminating stdin relay for that session. Either schedule _text_stdin_relay as a third task alongside the others, or consolidate into a single relay using websocket.receive() and inspecting the message type field.

[SECURITY] JWT in URL query param (AgentTerminal.tsx line 22, terminal_ws.py line 39)
Token appears in server access logs, browser history, and proxy logs. Matches the session_chat_ws.py pattern, so acceptable to track as a follow-up issue rather than block this PR — but should be filed before the feature ships publicly.


New Finding

[BUG] wsUrlRef never resets on sessionId change (AgentTerminal.tsx lines 88-90)

The guard on !wsUrlRef.current means the URL is built once and frozen for the component lifetime. If the component renders with a different sessionId (navigating between sessions), it continues connecting to the original session terminal. Replace the render-body assignment with useMemo keyed on sessionId so the URL and socket refresh when the session changes.


What is Good

  • Auth rejection tests cover the important failure paths (missing/invalid token, ended session, ownership mismatch).
  • useTerminalSocket hook design is clean: stable onData ref pattern, exponential backoff, proper cleanup on unmount.
  • ReconnectingOverlay differentiating connecting/reconnecting/error states is a nice touch.
  • Graceful subprocess teardown (terminate, wait, kill) is correct.

Summary: The credential leak is a blocker. The _text_stdin_relay dead-code bug and wsUrlRef stale-URL bug are both functional issues that should be fixed in this PR. JWT-in-URL can go to a follow-up issue.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
web-ui/src/__tests__/hooks/useTerminalSocket.test.ts (1)

105-107: ⚠️ Potential issue | 🟡 Minor

The test will fail due to reference inequality in the assertion.

The simulateBinaryMessage() passes bytes.buffer to the onmessage handler. The hook then creates a new Uint8Array from this buffer internally. Since Jest's toHaveBeenCalledWith() uses reference equality, the assertion will fail.

🔧 Proposed fix to compare array contents
     const bytes = new Uint8Array([104, 101, 108, 108, 111]); // "hello"
     act(() => ws.simulateBinaryMessage(bytes));
-    expect(onData).toHaveBeenCalledWith(bytes);
+    expect(onData).toHaveBeenCalledTimes(1);
+    expect(Array.from(onData.mock.calls[0][0])).toEqual([104, 101, 108, 108, 111]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/src/__tests__/hooks/useTerminalSocket.test.ts` around lines 105 - 107,
The test fails because simulateBinaryMessage passes bytes.buffer and the hook
creates a new Uint8Array, so reference equality fails; update the assertion for
the onData mock used by useTerminalSocket to compare contents instead of
references (e.g., assert the received value is a Uint8Array and use toEqual
against the original bytes or compare onData.mock.calls[0][0] to bytes) — locate
the simulateBinaryMessage call and the onData mock in the test and replace
expect(onData).toHaveBeenCalledWith(bytes) with a content-based equality check
for the Uint8Array.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web-ui/src/__tests__/hooks/useTerminalSocket.test.ts`:
- Around line 118-120: The test fails due to reference equality — both the hook
and the test create distinct Uint8Array instances with identical bytes. Update
the assertion after act(() => ws.simulateTextMessage('hi')) to fetch the actual
argument from the onData mock (onData.mock.calls[0][0]) and compare byte
contents rather than object identity (e.g., compare Array.from(actual) to
Array.from(expected) or use Buffer/TypedArray element-wise comparison) so the
test verifies equal bytes for the callback argument instead of reference
equality.

---

Duplicate comments:
In `@web-ui/src/__tests__/hooks/useTerminalSocket.test.ts`:
- Around line 105-107: The test fails because simulateBinaryMessage passes
bytes.buffer and the hook creates a new Uint8Array, so reference equality fails;
update the assertion for the onData mock used by useTerminalSocket to compare
contents instead of references (e.g., assert the received value is a Uint8Array
and use toEqual against the original bytes or compare onData.mock.calls[0][0] to
bytes) — locate the simulateBinaryMessage call and the onData mock in the test
and replace expect(onData).toHaveBeenCalledWith(bytes) with a content-based
equality check for the Uint8Array.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 121cad8b-b653-4183-b0a1-0f1c623634aa

📥 Commits

Reviewing files that changed from the base of the PR and between 6fbacba and 3d3254f.

📒 Files selected for processing (1)
  • web-ui/src/__tests__/hooks/useTerminalSocket.test.ts

Comment thread web-ui/src/__tests__/hooks/useTerminalSocket.test.ts
Test User added 2 commits March 31, 2026 20:42
…nings)

CodeRabbit critical fixes:
- terminal_ws.py: replace two competing receive_bytes/receive_text tasks with
  single unified _stdin_relay using websocket.receive() — fixes broken terminal input
- test_terminal_ws.py: remove unused asyncio import (ruff F401)
- AgentTerminal.tsx: encodeURIComponent(token) in WS URL query string
- useTerminalSocket.test.ts: value-based Uint8Array assertion

Pre-existing lint fixes (0 → 0 errors):
- tailwind.config.ts: require() → import typography from '@tailwindcss/typography'
- useAgentChat.ts: remove unused AgentChatStatus import
- useTaskStream.ts: remove unused SSEStatus import
- ShellCommandEvent.tsx: remove unused CheckmarkCircle01Icon, Cancel01Icon
- MarkdownEditor.tsx: remove unused TabsContent import
- prd/page.tsx: <a href> → <Link> (next/link)
- useAgentChat.test.ts: remove unused WsEventMap type and originalLocalStorage
- TaskBoardView.test.tsx: replace require('@/lib/api') with top-level import
- e2e/execution.spec.ts: rename unused route → _route
- e2e/fixtures/test-setup.ts: remove unused Page import; eslint-disable for
  Playwright fixture use() calls flagged as React hooks
- e2e/{prd,tasks,workspace}.spec.ts: remove unused mock-data imports
@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

PR Review: AgentTerminal — XTerm.js interactive terminal (#516)

Good implementation overall — clean separation of concerns, solid test coverage, and the reconnect logic in useTerminalSocket is well-designed. A few issues worth addressing before merge:


Security

1. JWT token in query parameter (terminal_ws.py, AgentTerminal.tsx)
Tokens in the URL get captured in server access logs, browser history, and any proxy logs. This is the same pattern as session_chat_ws.py, so it's a pre-existing concern — but worth flagging. Consider a short-lived ticket exchange or moving the token to the first WS message after handshake.

2. No rate limiting on the WebSocket endpoint (terminal_ws.py)
All API endpoints use SlowAPI rate limiting, but terminal_ws.router is mounted without any rate limit wrapper. A single user could open many bash sessions. Either apply a per-IP connection limit or cap concurrent sessions per user in the handler.

3. Reconnect ignores close codes (useTerminalSocket.ts:118-120)
Auth rejections (code 4001) and network drops both trigger the same retry logic. An auth failure will pointlessly retry 3 times. Check event.code in onclose and skip retries for 4001/4003.

ws.onclose = (event) => {
  wsRef.current = null;
  const isAuthFailure = event.code === 4001 || event.code === 4003;
  if (!isAuthFailure && retriesRef.current < maxRetries) {
    // retry...
  } else {
    setStatus('error');
  }
};

Correctness

4. Side effect during render (AgentTerminal.tsx:1275-1277)

if (typeof window !== 'undefined' && !wsUrlRef.current) {
  wsUrlRef.current = buildWsUrl(sessionId);
}

Mutating a ref during render is a React anti-pattern — it runs on every render pass including in StrictMode's double-invoke. Move this into a useMemo or useEffect.

5. No input size cap on stdin relay (terminal_ws.py:177-188)
There's no maximum size check on incoming WebSocket frames before writing to process.stdin. A client could send an arbitrarily large message. Add a guard: if len(raw_bytes) > 65536: continue (or similar).


Maintenance

6. Deprecated packages (package.json)
xterm@5.3.0 and xterm-addon-fit@0.8.0 are deprecated in favour of @xterm/xterm and @xterm/addon-fit (the npm registry flags this). The API is compatible; only import paths change. Worth migrating now rather than accumulating tech debt.

7. bash hardcoded (terminal_ws.py:130-137)
bash may not be present in minimal container images (Alpine uses sh). Consider falling back: shutil.which("bash") or "sh".


Minor / Nits

  • useTerminalSocket.ts:1529 — the eslint-disable comment to exclude close from the effect deps is correct (intentional), but a brief inline comment explaining why (close resets retries on unmount, not on URL change) would help future readers.
  • test_terminal_ws.pypytest.raises(Exception) is broad. Where possible, assert the exception type or message to make the test intent clearer.
  • AgentTerminal.tsx — the onData callback passed to useTerminalSocket is recreated on every render (inline lambda at line 1281). This is harmless because onDataRef is used internally, but the pattern is worth noting.

What's well done

  • The finally block in terminal_ws.py correctly handles terminate → timeout → kill for the subprocess.
  • Cleanup in the frontend useEffect (inputDisposer, resizeObserver, terminal.dispose) is thorough.
  • Test coverage for the hook (idle/connecting/open, binary/text frames, reconnect backoff, cleanup) is solid.
  • The unrelated lint fixes (unused imports, requireimport, <a><Link>) are clean and don't add noise.

Items 3, 4, and 6 are the most important to address. 1 and 2 can be tracked as follow-up issues if the session_chat_ws pattern is intentionally preserved for consistency.

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

Status check on prior reviews. Fixed: the dead stdin relay split is gone, current _stdin_relay handles both text and binary via websocket.receive() correctly. Test import cleanup is clean. Still blocking: [CRITICAL] os.environ.copy() credential leak (terminal_ws.py line 122) — users can run env and see ANTHROPIC_API_KEY, CODEFRAME_API_KEY_SECRET etc; needs an allowlist (PATH, HOME, USER, SHELL, TERM, LANG). [BUG] wsUrlRef never resets on sessionId change (AgentTerminal.tsx lines 88-90) — frozen URL can wire terminal to wrong session on navigation; use useMemo keyed on sessionId. Lower priority follow-ups: JWT in URL (matches session_chat_ws pattern so ok to track separately), reconnect ignores close codes (4001/4003 should not retry), deprecated xterm packages, no rate limit on WS endpoint, bash hardcoded. Credential leak is a hard blocker. wsUrlRef bug should also be fixed in this PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (6)
web-ui/__tests__/components/tasks/TaskBoardView.test.tsx (1)

245-245: Indentation inconsistency on mock setup lines.

These 6 lines are inside it() callbacks but have no leading indentation, while the rest of the test body is properly indented with 4 spaces. The code works correctly, but inconsistent formatting affects readability.

🔧 Example fix (apply same pattern to all 6 lines)
   it('calls stopExecution and mutates when Stop is clicked', async () => {
-tasksApi.stopExecution.mockResolvedValue(undefined);
+    tasksApi.stopExecution.mockResolvedValue(undefined);
     mockMutate.mockResolvedValue(undefined);

Also applies to: 259-259, 273-273, 323-323, 364-364, 389-389

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/__tests__/components/tasks/TaskBoardView.test.tsx` at line 245,
Several mock setup lines (e.g., tasksApi.stopExecution.mockResolvedValue(...))
inside it() callbacks are not indented to match the rest of the test bodies; fix
by adding the same 4-space indentation as the surrounding test code so those
lines align with the rest of the it() block. Update each occurrence (including
the instances at the positions referenced: the
tasksApi.stopExecution.mockResolvedValue line and the other similar
mockResolvedValue lines noted) to use the consistent 4-space indentation used
throughout TaskBoardView.test.tsx.
web-ui/src/__tests__/hooks/useTerminalSocket.test.ts (3)

111-122: Assertion style inconsistent with binary frame test.

The binary frame test (lines 107-108) uses Array.from() comparison to avoid reference equality issues, but this test still relies on toHaveBeenCalledWith(expected). While modern Jest performs deep equality on typed arrays, applying the same pattern as the binary test would ensure consistency and avoid potential fragility.

♻️ Suggested fix for consistency
     act(() => ws.simulateTextMessage('hi'));
-    const expected = new TextEncoder().encode('hi');
-    expect(onData).toHaveBeenCalledWith(expected);
+    const received = onData.mock.calls[0][0] as Uint8Array;
+    // 'hi' encodes to [104, 105]
+    expect(Array.from(received)).toEqual([104, 105]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/src/__tests__/hooks/useTerminalSocket.test.ts` around lines 111 - 122,
The test in useTerminalSocket.test.ts should avoid direct typed-array reference
equality by mirroring the binary-frame test: when asserting the onData call
after MockWebSocket.simulateTextMessage('hi'), convert the received arg and the
expected TextEncoder().encode('hi') to plain arrays (e.g., via Array.from) and
assert equality (use the same Array.from pattern as used in the binary-frame
test) so you compare values rather than references for the onData mock call.

74-217: Consider adding test coverage for WebSocket error events.

The test suite covers the core functionality well, but there's no test for the onerror handler behavior. If the hook handles errors differently from connection closes (e.g., immediate error state without retry), that behavior should be validated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/src/__tests__/hooks/useTerminalSocket.test.ts` around lines 74 - 217,
Add a test that covers WebSocket error events for useTerminalSocket: identify
the MockWebSocket instance (MockWebSocket.instances[0]), simulate an error
(implement or call a simulateError method on MockWebSocket), and assert the
hook's behavior (e.g., result.current.status becomes 'error' immediately or
follows retry logic) and that reconnects/cleanup behave as expected (respecting
maxRetries/retryDelay or no retries). Ensure the test calls act(() =>
ws.simulateError()) and then advances timers if needed
(jest.advanceTimersByTime) to validate retry behavior or final state; reference
the hook functions sendInput/sendResize only if needed to confirm socket
closed/cleaned up.

58-70: Consider restoring original WebSocket in afterEach.

The mock replaces global.WebSocket but never restores it. While Jest typically isolates test files, storing and restoring the original helps prevent interference if test isolation is incomplete.

♻️ Suggested improvement
+let originalWebSocket: typeof WebSocket | undefined;
+
 beforeEach(() => {
   MockWebSocket.instances = [];
+  originalWebSocket = (global as any).WebSocket;
   (global as any).WebSocket = MockWebSocket;
   // jsdom doesn't ship TextEncoder; polyfill from Node
   if (typeof (global as any).TextEncoder === 'undefined') {
     (global as any).TextEncoder = NodeTextEncoder;
   }
   jest.useFakeTimers();
 });

 afterEach(() => {
   jest.useRealTimers();
+  (global as any).WebSocket = originalWebSocket;
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/src/__tests__/hooks/useTerminalSocket.test.ts` around lines 58 - 70,
The test replaces global.WebSocket with MockWebSocket but never restores the
original, so modify the setup/teardown around beforeEach/afterEach: in the
beforeEach capture the existing global.WebSocket into a local variable (e.g.,
origWebSocket) before assigning MockWebSocket, and in afterEach restore
global.WebSocket from that saved origWebSocket (and clear
MockWebSocket.instances as needed); reference the existing beforeEach,
afterEach, MockWebSocket and uses of (global as any).WebSocket to locate where
to add the save/restore.
tests/ui/test_terminal_ws.py (2)

41-98: Auth tests cover key rejection scenarios but use overly broad exception catching.

The tests validate missing token, invalid token, session not found, ended session, and ownership mismatch. However, pytest.raises(Exception) is too broad and could mask unrelated failures.

♻️ Consider catching specific WebSocket exceptions

FastAPI's TestClient raises WebSocketDisconnect or similar when the connection is rejected. Using a more specific exception type would make test failures clearer:

+from starlette.websockets import WebSocketDisconnect

 def test_missing_token_closes_4001(self):
     app = _make_app()
     client = TestClient(app)
-    with pytest.raises(Exception):
+    with pytest.raises(Exception):  # WebSocket rejection raises generic Exception in TestClient
         # No token → closed before accepting; TestClient raises on non-101
         with client.websocket_connect("/ws/sessions/s1/terminal"):
             pass

If WebSocketDisconnect is not directly catchable, adding a comment explaining why Exception is used would improve clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/ui/test_terminal_ws.py` around lines 41 - 98, The tests in class
TestTerminalWsAuth use pytest.raises(Exception) which is too broad; update each
test (e.g., test_missing_token_closes_4001, test_invalid_token_closes_4001,
test_valid_token_session_not_found, test_valid_token_ended_session,
test_ownership_mismatch_closes) to assert the specific WebSocket disconnect
exception instead of Exception — import WebSocketDisconnect from
starlette.websockets (or the appropriate WebSocketDisconnect used by TestClient)
and replace pytest.raises(Exception) with pytest.raises(WebSocketDisconnect); if
that specific exception cannot be caught by TestClient in your test environment,
leave the Exception but add a brief comment above the with pytest.raises(...)
explaining why the broad Exception is required.

143-170: Test verifies no crash but lacks assertion on behavior.

The test sends a resize message and implicitly passes if no exception is raised. Adding an assertion (e.g., that stdin.write was not called with the resize JSON) would strengthen the test.

💡 Proposed enhancement: assert resize message is not forwarded
         with client.websocket_connect("/ws/sessions/s1/terminal?token=x") as ws:
             # Sending a resize message should not raise
             ws.send_text(json.dumps({"type": "resize", "cols": 80, "rows": 24}))
+            # Verify resize message was NOT written to subprocess stdin
+            # (stdin.write should not have been called with the resize JSON)
+            for call in mock_proc.stdin.write.call_args_list:
+                if call.args:
+                    written = call.args[0]
+                    assert b'"type": "resize"' not in written, "resize should not be forwarded to stdin"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/ui/test_terminal_ws.py` around lines 143 - 170, The test
test_resize_message_does_not_crash should assert that the resize JSON is not
forwarded to the process: after calling
ws.send_text(json.dumps({"type":"resize","cols":80,"rows":24})), add an
assertion that mock_proc.stdin.write (or
mock_proc.stdin.write.assert_not_called()/assert_not_awaited) was not invoked
with the resize payload (or not invoked at all), referencing mock_proc.stdin and
ws.send_text to locate the relevant mocks and call; ensure to use the
appropriate AsyncMock assertion (e.g., assert_not_awaited or assert_not_called)
for the async stdin write.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@codeframe/ui/routers/terminal_ws.py`:
- Around line 116-117: The current code copies the full server environment into
env using os.environ.copy() which may leak secrets; instead construct a minimal,
explicit environment for the subprocess (keep TERM="xterm-256color" and any safe
variables like PATH if required) or build a whitelist of allowed keys and
populate env only from that list; locate the env assignment and the
subprocess-launching call in terminal_ws.py (the env variable set by env["TERM"]
= "xterm-256color") and replace the os.environ.copy() usage with an explicit
dict or a filtered os.environ that only includes approved keys.
- Line 111: Do not silently default workspace_path to ".", instead assert/read
it explicitly from the session and fail fast if missing: replace the fallback
usage of session.get("workspace_path") that assigns to workspace_path with a
guard that checks session["workspace_path"] (or raises/returns an error) and
log/raise a descriptive error (including the session id or user context) so the
websocket/handler in terminal_ws.py does not spawn a shell in the server CWD;
refer to the workspace_path variable and the session.get call to locate the
change and ensure any caller (e.g., the terminal connection initializer) handles
the raised error cleanly.
- Around line 100-103: The current check in the terminal websocket handler that
only forbids "ended" sessions is too permissive — change the condition in the
block that retrieves the session (session = await
asyncio.to_thread(db.interactive_sessions.get, session_id)) from checking
session.get("state") == "ended" to session.get("state") != "active" so only
"active" sessions are allowed (and likewise update the identical check in
session_chat_ws at the referenced location); keep the existing
websocket.close(...) call but ensure it triggers when state is not "active".

---

Nitpick comments:
In `@tests/ui/test_terminal_ws.py`:
- Around line 41-98: The tests in class TestTerminalWsAuth use
pytest.raises(Exception) which is too broad; update each test (e.g.,
test_missing_token_closes_4001, test_invalid_token_closes_4001,
test_valid_token_session_not_found, test_valid_token_ended_session,
test_ownership_mismatch_closes) to assert the specific WebSocket disconnect
exception instead of Exception — import WebSocketDisconnect from
starlette.websockets (or the appropriate WebSocketDisconnect used by TestClient)
and replace pytest.raises(Exception) with pytest.raises(WebSocketDisconnect); if
that specific exception cannot be caught by TestClient in your test environment,
leave the Exception but add a brief comment above the with pytest.raises(...)
explaining why the broad Exception is required.
- Around line 143-170: The test test_resize_message_does_not_crash should assert
that the resize JSON is not forwarded to the process: after calling
ws.send_text(json.dumps({"type":"resize","cols":80,"rows":24})), add an
assertion that mock_proc.stdin.write (or
mock_proc.stdin.write.assert_not_called()/assert_not_awaited) was not invoked
with the resize payload (or not invoked at all), referencing mock_proc.stdin and
ws.send_text to locate the relevant mocks and call; ensure to use the
appropriate AsyncMock assertion (e.g., assert_not_awaited or assert_not_called)
for the async stdin write.

In `@web-ui/__tests__/components/tasks/TaskBoardView.test.tsx`:
- Line 245: Several mock setup lines (e.g.,
tasksApi.stopExecution.mockResolvedValue(...)) inside it() callbacks are not
indented to match the rest of the test bodies; fix by adding the same 4-space
indentation as the surrounding test code so those lines align with the rest of
the it() block. Update each occurrence (including the instances at the positions
referenced: the tasksApi.stopExecution.mockResolvedValue line and the other
similar mockResolvedValue lines noted) to use the consistent 4-space indentation
used throughout TaskBoardView.test.tsx.

In `@web-ui/src/__tests__/hooks/useTerminalSocket.test.ts`:
- Around line 111-122: The test in useTerminalSocket.test.ts should avoid direct
typed-array reference equality by mirroring the binary-frame test: when
asserting the onData call after MockWebSocket.simulateTextMessage('hi'), convert
the received arg and the expected TextEncoder().encode('hi') to plain arrays
(e.g., via Array.from) and assert equality (use the same Array.from pattern as
used in the binary-frame test) so you compare values rather than references for
the onData mock call.
- Around line 74-217: Add a test that covers WebSocket error events for
useTerminalSocket: identify the MockWebSocket instance
(MockWebSocket.instances[0]), simulate an error (implement or call a
simulateError method on MockWebSocket), and assert the hook's behavior (e.g.,
result.current.status becomes 'error' immediately or follows retry logic) and
that reconnects/cleanup behave as expected (respecting maxRetries/retryDelay or
no retries). Ensure the test calls act(() => ws.simulateError()) and then
advances timers if needed (jest.advanceTimersByTime) to validate retry behavior
or final state; reference the hook functions sendInput/sendResize only if needed
to confirm socket closed/cleaned up.
- Around line 58-70: The test replaces global.WebSocket with MockWebSocket but
never restores the original, so modify the setup/teardown around
beforeEach/afterEach: in the beforeEach capture the existing global.WebSocket
into a local variable (e.g., origWebSocket) before assigning MockWebSocket, and
in afterEach restore global.WebSocket from that saved origWebSocket (and clear
MockWebSocket.instances as needed); reference the existing beforeEach,
afterEach, MockWebSocket and uses of (global as any).WebSocket to locate where
to add the save/restore.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 41882019-8a8a-4280-9832-c12d0dff9296

📥 Commits

Reviewing files that changed from the base of the PR and between 3d3254f and d0400cf.

📒 Files selected for processing (17)
  • codeframe/ui/routers/terminal_ws.py
  • tests/ui/test_terminal_ws.py
  • web-ui/__tests__/components/tasks/TaskBoardView.test.tsx
  • web-ui/e2e/execution.spec.ts
  • web-ui/e2e/fixtures/test-setup.ts
  • web-ui/e2e/prd.spec.ts
  • web-ui/e2e/tasks.spec.ts
  • web-ui/e2e/workspace.spec.ts
  • web-ui/src/__tests__/hooks/useAgentChat.test.ts
  • web-ui/src/__tests__/hooks/useTerminalSocket.test.ts
  • web-ui/src/app/prd/page.tsx
  • web-ui/src/components/execution/ShellCommandEvent.tsx
  • web-ui/src/components/prd/MarkdownEditor.tsx
  • web-ui/src/components/sessions/AgentTerminal.tsx
  • web-ui/src/hooks/useAgentChat.ts
  • web-ui/src/hooks/useTaskStream.ts
  • web-ui/tailwind.config.ts
💤 Files with no reviewable changes (1)
  • web-ui/src/tests/hooks/useAgentChat.test.ts
✅ Files skipped from review due to trivial changes (9)
  • web-ui/src/components/execution/ShellCommandEvent.tsx
  • web-ui/e2e/prd.spec.ts
  • web-ui/src/components/prd/MarkdownEditor.tsx
  • web-ui/e2e/workspace.spec.ts
  • web-ui/src/hooks/useTaskStream.ts
  • web-ui/e2e/tasks.spec.ts
  • web-ui/e2e/execution.spec.ts
  • web-ui/e2e/fixtures/test-setup.ts
  • web-ui/src/hooks/useAgentChat.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • web-ui/src/components/sessions/AgentTerminal.tsx

Comment on lines +100 to +103
session = await asyncio.to_thread(db.interactive_sessions.get, session_id)
if session is None or session.get("state") == "ended":
await websocket.close(code=4004, reason="Session not found or ended")
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check what session states exist in the codebase
rg -n "state.*=.*['\"]" --type py -g '*session*' | head -30
# Also check the session model/schema for state enum
ast-grep --pattern 'state = $_'

Repository: frankbria/codeframe

Length of output: 12943


🏁 Script executed:

#!/bin/bash
# Find session state enum or constants
rg -n "state.*=.*\[" --type py | grep -i session | head -20

# Look for the interactive_sessions schema/model
fd -e py -path '*interactive*session*' | head -10

# Check for state validation or enum
rg -n "class.*State|State.*=|VALID.*STATE|ACTIVE.*STATE" --type py | head -20

Repository: frankbria/codeframe

Length of output: 2270


🏁 Script executed:

#!/bin/bash
# Look at the test that shows paused transition to understand intent
sed -n '240,260p' tests/unit/test_interactive_sessions_api.py

# Check what happens in the terminal websocket - get more context
cat -n codeframe/ui/routers/terminal_ws.py | head -120

Repository: frankbria/codeframe

Length of output: 6079


🏁 Script executed:

#!/bin/bash
# Find all places that set session state to understand the state machine
rg -B2 -A2 "state.*=.*['\"]" --type py codeframe/persistence/repositories/interactive_sessions.py

# Check the interactive sessions service/repository for state handling
cat -n codeframe/persistence/repositories/interactive_sessions.py | head -150

Repository: frankbria/codeframe

Length of output: 6872


🏁 Script executed:

#!/bin/bash
# Search for VALID_STATES definition
rg -n "VALID_STATES|SESSION.*STATE" --type py

# Search for where "paused" state is used
rg -B3 -A3 '"paused"' --type py

# Check if there's a session model/enum
fd -e py | xargs grep -l "class.*Session" | grep -E "model|schema|type" | head -5

Repository: frankbria/codeframe

Length of output: 5476


🏁 Script executed:

#!/bin/bash
# Check session_chat_ws.py for the same pattern
cat -n codeframe/ui/routers/session_chat_ws.py | sed -n '145,165p'

# Look for any session state machine or state transitions documentation
fd -e md -o -e txt | xargs grep -l "session.*state" 2>/dev/null | head -5

Repository: frankbria/codeframe

Length of output: 1124


Only "active" sessions should have terminal access; currently "paused" sessions are allowed.

The state check rejects only "ended" sessions, but "paused" is also an invalid state per VALID_STATES = frozenset({"active", "paused", "ended"}) in interactive_sessions_v2.py. Change the check from session.get("state") == "ended" to session.get("state") != "active" to ensure only active sessions can access the terminal. This same issue exists in session_chat_ws.py:154.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/ui/routers/terminal_ws.py` around lines 100 - 103, The current
check in the terminal websocket handler that only forbids "ended" sessions is
too permissive — change the condition in the block that retrieves the session
(session = await asyncio.to_thread(db.interactive_sessions.get, session_id))
from checking session.get("state") == "ended" to session.get("state") !=
"active" so only "active" sessions are allowed (and likewise update the
identical check in session_chat_ws at the referenced location); keep the
existing websocket.close(...) call but ensure it triggers when state is not
"active".

Comment thread codeframe/ui/routers/terminal_ws.py Outdated
Comment thread codeframe/ui/routers/terminal_ws.py Outdated
…or subprocess

- Replace silent workspace_path fallback to '.' with explicit error: closes
  the WebSocket with 4008 if session has no workspace configured, preventing
  bash from spawning in the server CWD
- Replace os.environ.copy() with a minimal explicit env dict: only TERM, HOME,
  PATH, SHELL, LANG, USER are passed to the subprocess, preventing server
  secrets (API keys, DB credentials) from leaking to the child process
@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

Follow-up Review

Fixes confirmed

Both blockers from prior reviews are resolved in the current diff:

  • Credential leak fixedterminal_ws.py now uses a minimal allowlist env dict (TERM, HOME, PATH, SHELL, LANG, USER). Server secrets are no longer exposed to the subprocess.
  • Dead stdin relay fixed — single _stdin_relay using websocket.receive() now handles both text and binary frames correctly.

Still open: wsUrlRef stale URL bug

AgentTerminal.tsx lines 88–90 still contain the render-body mutation:

if (typeof window !== 'undefined' && !wsUrlRef.current) {
  wsUrlRef.current = buildWsUrl(sessionId);
}

The !wsUrlRef.current guard means the URL is frozen after the first build. If the component re-renders with a different sessionId (user navigates between sessions), the terminal silently stays connected to the original session. Fix before merge:

const wsUrl = useMemo(
  () => (typeof window !== 'undefined' ? buildWsUrl(sessionId) : null),
  [sessionId]
);
// pass wsUrl to useTerminalSocket instead of wsUrlRef.current

Lower-priority items (can be follow-up issues)

These were flagged in prior reviews and remain unchanged — acceptable to track separately since they match the existing session_chat_ws pattern or are non-blocking:

  • JWT in URL query param (matches session_chat_ws.py pattern — file a follow-up issue before public ship)
  • Reconnect retries on auth failures — onclose should skip retries for codes 4001/4003
  • Deprecated xterm / xterm-addon-fit packages (easy migration to @xterm/xterm / @xterm/addon-fit)
  • No rate limit on the WS endpoint
  • bash hardcoded (consider shutil.which('bash') or 'sh' for Alpine images)

The wsUrlRef stale URL bug is the one remaining issue that should be fixed in this PR. Everything else can be tracked as follow-ups.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
codeframe/ui/routers/terminal_ws.py (1)

100-103: ⚠️ Potential issue | 🟠 Major

Session state check should only allow "active" sessions.

The current check rejects only "ended" sessions, but "paused" sessions should also be denied terminal access. Change the condition to explicitly require state == "active".

     session = await asyncio.to_thread(db.interactive_sessions.get, session_id)
-    if session is None or session.get("state") == "ended":
-        await websocket.close(code=4004, reason="Session not found or ended")
+    if session is None:
+        await websocket.close(code=4004, reason="Session not found")
+        return
+    if session.get("state") != "active":
+        await websocket.close(code=4004, reason="Session is not active")
         return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/ui/routers/terminal_ws.py` around lines 100 - 103, The session
validation currently only rejects sessions with state "ended"; update the check
after session = await asyncio.to_thread(db.interactive_sessions.get, session_id)
to require session.get("state") == "active" (i.e., treat any non-"active" state,
including "paused" or missing state, as invalid). Replace the existing condition
(session is None or session.get("state") == "ended") with a negative check
(session is None or session.get("state") != "active") so that
websocket.close(code=4004, reason=...) runs for all non-active sessions.
🧹 Nitpick comments (1)
codeframe/ui/routers/terminal_ws.py (1)

146-148: Consider explicit guards instead of assert for production resilience.

The assert statements serve as type narrowing, but they can be disabled with python -O. Since these nested functions are created only after successful subprocess spawn, this is unlikely to cause issues in practice, but explicit guards would be more defensive:

♻️ Optional: Replace asserts with explicit guards
         async def _stdout_relay() -> None:
-            assert process is not None
-            assert process.stdout is not None
+            if process is None or process.stdout is None:
+                return
             try:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/ui/routers/terminal_ws.py` around lines 146 - 148, The nested
function _stdout_relay currently uses assert process is not None and assert
process.stdout is not None; replace those asserts with explicit runtime guards:
check if process is None or process.stdout is None at the start of
_stdout_relay, log or raise a clear RuntimeError (or return early) with
contextual message including the function name, and ensure any callers handle
that error/path; reference the symbols _stdout_relay, process, and
process.stdout when making the change so the guard replaces the asserts and
provides deterministic behavior in production.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@codeframe/ui/routers/terminal_ws.py`:
- Around line 105-109: The ownership check currently assumes
session.get("user_id") exists but the interactive_sessions schema lacks that
column, so update the check to fail-closed: if session.get("user_id") is None or
int(session.get("user_id")) != user_id then call websocket.close(code=4003,
reason="Forbidden: session belongs to another user") and return; additionally,
if you can, replace reliance on session data by validating against the
persistent record (e.g., query the interactive_sessions record for the session
id and compare its owner column to user_id) so the check in terminal_ws.py (the
session_user_id / session.get("user_id") branch around websocket.close) always
enforces ownership even when schema/session data is missing.

---

Duplicate comments:
In `@codeframe/ui/routers/terminal_ws.py`:
- Around line 100-103: The session validation currently only rejects sessions
with state "ended"; update the check after session = await
asyncio.to_thread(db.interactive_sessions.get, session_id) to require
session.get("state") == "active" (i.e., treat any non-"active" state, including
"paused" or missing state, as invalid). Replace the existing condition (session
is None or session.get("state") == "ended") with a negative check (session is
None or session.get("state") != "active") so that websocket.close(code=4004,
reason=...) runs for all non-active sessions.

---

Nitpick comments:
In `@codeframe/ui/routers/terminal_ws.py`:
- Around line 146-148: The nested function _stdout_relay currently uses assert
process is not None and assert process.stdout is not None; replace those asserts
with explicit runtime guards: check if process is None or process.stdout is None
at the start of _stdout_relay, log or raise a clear RuntimeError (or return
early) with contextual message including the function name, and ensure any
callers handle that error/path; reference the symbols _stdout_relay, process,
and process.stdout when making the change so the guard replaces the asserts and
provides deterministic behavior in production.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 05185d6e-0d6b-485a-80ce-52b711a2ca69

📥 Commits

Reviewing files that changed from the base of the PR and between d0400cf and a93d069.

📒 Files selected for processing (1)
  • codeframe/ui/routers/terminal_ws.py

Comment on lines +105 to +109
# --- Ownership check ---
session_user_id = session.get("user_id")
if session_user_id is not None and int(session_user_id) != user_id:
await websocket.close(code=4003, reason="Forbidden: session belongs to another user")
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify if interactive_sessions table schema includes user_id column
rg -n "user_id" codeframe/persistence/repositories/interactive_sessions.py

# Check the schema definition
rg -n "CREATE TABLE.*interactive_sessions" -A 20 codeframe/persistence/

# Check if session_chat_ws has the same pattern
rg -n "session.get.*user_id" codeframe/ui/routers/

Repository: frankbria/codeframe

Length of output: 1882


Critical: Ownership check is silently bypassed due to missing user_id column in schema.

The interactive_sessions table schema (defined in codeframe/persistence/schema_manager.py:842-856) does not include a user_id column. When session.get("user_id") is called at line 106, it will always return None, causing the condition at line 107 to be False and the entire ownership validation to be unreachable. This allows any authenticated user to access any session's terminal.

Implement fail-closed behavior:

Suggested fix
     # --- Ownership check ---
     session_user_id = session.get("user_id")
-    if session_user_id is not None and int(session_user_id) != user_id:
+    if session_user_id is None:
+        logger.warning("Session %s missing user_id; denying terminal access", session_id)
+        await websocket.close(code=4003, reason="Session ownership cannot be verified")
+        return
+    if int(session_user_id) != user_id:
         await websocket.close(code=4003, reason="Forbidden: session belongs to another user")
         return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/ui/routers/terminal_ws.py` around lines 105 - 109, The ownership
check currently assumes session.get("user_id") exists but the
interactive_sessions schema lacks that column, so update the check to
fail-closed: if session.get("user_id") is None or int(session.get("user_id")) !=
user_id then call websocket.close(code=4003, reason="Forbidden: session belongs
to another user") and return; additionally, if you can, replace reliance on
session data by validating against the persistent record (e.g., query the
interactive_sessions record for the session id and compare its owner column to
user_id) so the check in terminal_ws.py (the session_user_id /
session.get("user_id") branch around websocket.close) always enforces ownership
even when schema/session data is missing.

Correctness:
- useTerminalSocket: skip retry on auth/authz close codes (4001/4003/4004/4008)
  to avoid pointless retry loops when credentials are permanently rejected
- AgentTerminal: move wsUrl build from render-time side effect into useMemo
  (safe under React StrictMode double-invoke)
- terminal_ws.py: cap stdin frame size at 64KB to prevent unbounded writes

Security:
- terminal_ws.py: enforce per-user concurrent terminal cap (max 3) with
  in-process counter; decrement in finally block on disconnect

Maintenance:
- Migrate xterm/xterm-addon-fit → @xterm/xterm/@xterm/addon-fit (scoped
  packages; old packages deprecated on npm registry)
- terminal_ws.py: use shutil.which("bash") or "sh" fallback for Alpine/minimal
  container compatibility

Tests:
- Update MockWebSocket.close/simulateClose to pass CloseEvent with code
- Add test: auth failure close code skips retry and goes straight to error
@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

Final Review Pass

All three issues from prior reviews are now resolved in the current diff:

  • Credential leak fixed — minimal env allowlist (TERM, HOME, PATH, SHELL, LANG, USER) prevents server secrets from reaching the subprocess.
  • Dead stdin relay fixed — single _stdin_relay using websocket.receive() dispatches both text and binary frames correctly.
  • wsUrlRef stale URL fixed — useMemo(() => buildWsUrl(sessionId), [sessionId]) replaces the render-body mutation; terminal reconnects correctly when sessionId changes.

Two other items from earlier reviews are also resolved as a side effect:

  • Reconnect logic skips retries for permanent failure codes (4001/4003/4004/4008).
  • @xterm/xterm / @xterm/addon-fit (non-deprecated packages) are now the dependencies.
  • shutil.which("bash") or shutil.which("sh") or "sh" handles Alpine and other minimal images.

Remaining (acceptable follow-ups)

These can be tracked as separate issues rather than blocking this PR:

  1. JWT in URL query param — token appears in server logs and browser history. Pre-existing pattern from session_chat_ws.py. File a follow-up before the feature ships publicly.
  2. No rate limit on the WS endpoint — all REST endpoints use SlowAPI but the terminal WS is uncapped. The per-user connection cap (3 max) provides some protection; a rate limit on the handshake itself would be better.
  3. Paused session state — only state == "ended" is rejected. "paused" sessions can still spawn a terminal. Consider blocking non-"active" states explicitly.

This PR is ready to merge. All blockers are resolved and the implementation is solid.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
codeframe/ui/routers/terminal_ws.py (2)

105-108: ⚠️ Potential issue | 🟠 Major

State check allows "paused" sessions to spawn terminals.

The condition only rejects "ended" sessions, but "paused" sessions should also be denied terminal access. Only "active" sessions should have an interactive terminal.

Proposed fix
     session = await asyncio.to_thread(db.interactive_sessions.get, session_id)
-    if session is None or session.get("state") == "ended":
+    if session is None or session.get("state") != "active":
         await websocket.close(code=4004, reason="Session not found or ended")
         return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/ui/routers/terminal_ws.py` around lines 105 - 108, The session
state check allows "paused" sessions to open terminals; update the condition
around the session fetched via db.interactive_sessions.get (variable session) to
only permit sessions with state == "active" (or explicitly deny states "paused"
and "ended") and call websocket.close(code=4004, reason="Session not found,
paused, or ended") when denied; adjust the check near the websocket handling
logic so any session.get("state") != "active" triggers the close.

110-114: ⚠️ Potential issue | 🔴 Critical

Critical: Ownership check is ineffective—any authenticated user can access any session's terminal.

The interactive_sessions table has no user_id column. When the ownership check attempts session.get("user_id") at line 111, it returns None, causing the conditional at line 112 to short-circuit and skip access denial. Combined with the fact that sessions are created without user association (as noted in interactive_sessions_v2.py), any authenticated user can connect to any session's terminal.

Use a fail-closed approach: deny access if ownership cannot be verified.

Proposed fix
     # --- Ownership check ---
     session_user_id = session.get("user_id")
-    if session_user_id is not None and int(session_user_id) != user_id:
+    if session_user_id is None:
+        logger.warning("Session %s has no user_id; denying terminal access", session_id)
+        await websocket.close(code=4003, reason="Session ownership cannot be verified")
+        return
+    if int(session_user_id) != user_id:
         await websocket.close(code=4003, reason="Forbidden: session belongs to another user")
         return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/ui/routers/terminal_ws.py` around lines 110 - 114, The ownership
check is currently ineffective because session.get("user_id") can be None;
update the logic to fail-closed: if session_user_id is None OR
int(session_user_id) != user_id then immediately call websocket.close(code=4003,
reason="Forbidden: session belongs to another user") and return. Additionally,
because the interactive_sessions table has no user_id, add a verification step
that queries the session owner from the authoritative source in
interactive_sessions_v2 (e.g., call the existing lookup/query function in
interactive_sessions_v2.py to retrieve the session owner by session_id) and
compare that owner to user_id; if that lookup yields no owner or a mismatch,
close the websocket. Ensure you reference the variables session_user_id,
session.get("user_id"), and the websocket.close(...) call when making the
change.
🧹 Nitpick comments (1)
web-ui/src/components/sessions/AgentTerminal.tsx (1)

37-51: Consider using Hugeicons for consistency.

The coding guidelines specify using @hugeicons/react for icons. If Hugeicons has a suitable loading/spinner icon, consider using it for consistency with the rest of the UI.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/src/components/sessions/AgentTerminal.tsx` around lines 37 - 51,
Replace the inline SVG spinner in AgentTerminal.tsx with the Hugeicons spinner
component to match project conventions: import the appropriate spinner icon from
`@hugeicons/react` (e.g., Spinner or SpinnerSolid) and use that component in place
of the SVG block shown in the diff (the <svg ...> / </svg> spinner). Ensure you
pass the same sizing and color classes (e.g., animate-spin h-5 w-5
text-purple-400) to the Hugeicons component so appearance and animation remain
consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@codeframe/ui/routers/terminal_ws.py`:
- Around line 105-108: The session state check allows "paused" sessions to open
terminals; update the condition around the session fetched via
db.interactive_sessions.get (variable session) to only permit sessions with
state == "active" (or explicitly deny states "paused" and "ended") and call
websocket.close(code=4004, reason="Session not found, paused, or ended") when
denied; adjust the check near the websocket handling logic so any
session.get("state") != "active" triggers the close.
- Around line 110-114: The ownership check is currently ineffective because
session.get("user_id") can be None; update the logic to fail-closed: if
session_user_id is None OR int(session_user_id) != user_id then immediately call
websocket.close(code=4003, reason="Forbidden: session belongs to another user")
and return. Additionally, because the interactive_sessions table has no user_id,
add a verification step that queries the session owner from the authoritative
source in interactive_sessions_v2 (e.g., call the existing lookup/query function
in interactive_sessions_v2.py to retrieve the session owner by session_id) and
compare that owner to user_id; if that lookup yields no owner or a mismatch,
close the websocket. Ensure you reference the variables session_user_id,
session.get("user_id"), and the websocket.close(...) call when making the
change.

---

Nitpick comments:
In `@web-ui/src/components/sessions/AgentTerminal.tsx`:
- Around line 37-51: Replace the inline SVG spinner in AgentTerminal.tsx with
the Hugeicons spinner component to match project conventions: import the
appropriate spinner icon from `@hugeicons/react` (e.g., Spinner or SpinnerSolid)
and use that component in place of the SVG block shown in the diff (the <svg
...> / </svg> spinner). Ensure you pass the same sizing and color classes (e.g.,
animate-spin h-5 w-5 text-purple-400) to the Hugeicons component so appearance
and animation remain consistent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ecb94ae9-e646-47da-87b6-e0d44cf604ce

📥 Commits

Reviewing files that changed from the base of the PR and between a93d069 and e104221.

⛔ Files ignored due to path filters (1)
  • web-ui/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (5)
  • codeframe/ui/routers/terminal_ws.py
  • web-ui/package.json
  • web-ui/src/__tests__/hooks/useTerminalSocket.test.ts
  • web-ui/src/components/sessions/AgentTerminal.tsx
  • web-ui/src/hooks/useTerminalSocket.ts
✅ Files skipped from review due to trivial changes (2)
  • web-ui/package.json
  • web-ui/src/tests/hooks/useTerminalSocket.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • web-ui/src/hooks/useTerminalSocket.ts

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

Review: AgentTerminal WebSocket Terminal (#506)

Good implementation overall. The architecture is clean, the security-conscious subprocess env (not copying os.environ) is the right call, and test coverage is solid on both sides. A few items worth addressing before merge:


Bug: Close code 4029 not in the no-retry list

useTerminalSocket.ts correctly skips retries for auth/authz codes (4001, 4003, 4004, 4008), but the backend returns 4029 when the per-user terminal cap is exceeded. Since 4029 is not in the permanent-failure list, hitting the cap triggers exponential-backoff retries that all fail with 4029 again — a tight retry loop until maxRetries exhausts.

Fix in web-ui/src/hooks/useTerminalSocket.ts:

const isPermanentFailure =
  event.code === 4001 || event.code === 4003 ||
  event.code === 4004 || event.code === 4008 || event.code === 4029;

Session state check (echoing CodeRabbit)

CodeRabbit has flagged this four times and it remains unresolved. The check session.get("state") == "ended" passes "paused" sessions through to terminal spawn. Flip to an allowlist in terminal_ws.py:

if session is None or session.get("state") != "active":
    await websocket.close(code=4004, reason="Session not found or not active")
    return

workspace_path: existence check before subprocess spawn

workspace_path comes from the DB (trusted), but if the directory was cleaned up externally, create_subprocess_exec raises FileNotFoundError logged as an opaque server error. A guard before spawning helps:

import pathlib
if not pathlib.Path(workspace_path).is_dir():
    await websocket.close(code=4008, reason="Session workspace not found on disk")
    return

Minor: empty USER env var

os.environ.get("USER", "") passes an empty string in containers where USER is unset. "user" is a less surprising fallback.


Things that look good

  • Not copying os.environ to the subprocess — right call, well-documented in comments.
  • Exponential-backoff reconnect with no-retry on auth failures — correct pattern.
  • _user_terminal_counts resets on restart, acknowledged in code; fine as a best-effort cap.
  • XTerm dynamic import for SSR safety and ResizeObserver for auto-fit.
  • 7 pytest + 9 Jest tests covering the core paths.
  • Tailwind requireimport and <a><Link> cleanups are welcome.

The 4029 close code omission is the only functional bug. The session state check is worth fixing before staging given how many times CodeRabbit has flagged it.

@frankbria frankbria merged commit c73024b into main Apr 1, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Frontend: AgentTerminal component — XTerm.js interactive terminal for session workspace

1 participant