Skip to content

Feat/terminal#23

Closed
leszek3737 wants to merge 5 commits intomainfrom
feat/terminal
Closed

Feat/terminal#23
leszek3737 wants to merge 5 commits intomainfrom
feat/terminal

Conversation

@leszek3737
Copy link
Copy Markdown
Owner

@leszek3737 leszek3737 commented Apr 8, 2026

Type

  • Agent template (TOML)
  • Skill (Python/JS/Prompt)
  • Channel adapter
  • LLM provider
  • Built-in tool
  • Bug fix
  • Feature (Rust)
  • Documentation / Translation
  • Refactor / Performance
  • CI / Tooling
  • Other

Summary

Changes

Attribution

  • This PR preserves author attribution for any adapted prior work (Co-authored-by, commit preservation, or explicit credit in the PR body)

Testing

  • cargo clippy --workspace --all-targets -- -D warnings passes
  • cargo test --workspace passes
  • Live integration tested (if applicable)

Security

  • No new unsafe code
  • No secrets or API keys in diff
  • User input validated at boundaries

Summary by Sourcery

Add a browser-based terminal feature backed by a PTY over authenticated WebSocket, expose supporting APIs, and integrate it into the dashboard navigation and install workflow.

New Features:

  • Introduce a WebSocket-based terminal API backed by a portable PTY session for interactive shell access.
  • Add a dashboard Terminal page using xterm.js to provide an in-browser terminal connected to the backend terminal WebSocket.
  • Expose terminal routes and helpers in the API server and wire them into the main router and OpenAPI spec.
  • Add a unix-only install-full just task to install both the CLI and a fresh dashboard build into the user configuration directory.

Enhancements:

  • Export WebSocket helper functions and connection guard types for reuse by the new terminal module.
  • Adjust runtime and context engine code to clone runtime values and improve logging formatting without changing behavior.

Build:

  • Add the portable-pty crate dependency for PTY support in the API service.
  • Add xterm.js and its fit addon as dashboard frontend dependencies for the terminal UI.

Documentation:

  • Extend i18n locale files and OpenAPI documentation to cover the new terminal functionality.

Tests:

  • Add unit tests for terminal routing, message validation, message serialization, and shell selection behavior.

houko and others added 5 commits April 8, 2026 20:26
…ntend

Implements a minimal terminal over WebSocket for the dashboard.

Backend (librefang-api):
- New route: GET /api/terminal/ws with Bearer/?token= auth
- Per-IP connection limit via ws_tracker() from existing ws module
- Idle timeout and ping/pong via existing ws_idle_timeout_secs config
- Protocol: Client sends input/resize/close; server sends
  started/output/exit/error with UTF-8/binary detection
  (base64 encoding for non-UTF-8 output)
- PTY abstraction via portable-pty with shell selection:
  Unix: $SHELL fallback /bin/sh, Windows: %COMSPEC% fallback cmd.exe
- Exit notifications on client close, idle timeout, and server close
- WsConnectionGuard properly scoped into handler

Frontend (dashboard):
- TerminalPage with @xterm/xterm + @xterm/addon-fit
- Dark theme, auto-reconnect with 2s delay
- Full i18n: en.json and zh.json translations
- Terminal tab in sidebar Advanced section (after Comms)
- Command palette entry

Fixes: WsConnectionGuard scope, PTY orphan processes,
shell path accuracy, binary flag, validate() call, i18n,
auto-reconnect, exit notifications, msg.data/content validation.
Clone PluginRuntime before moves in loops (prewarm, on_event spawn),
fix Cow<str> trait bounds (AsRef<OsStr> for cmd.env, Display for tracing),
and use std::io::Error::other per clippy.
Update pnpm-lock.yaml for @xterm dependencies and add `just install-full`
that copies built dashboard to ~/.librefang/dashboard with a .version file
to prevent sync_dashboard from overwriting with stale GitHub release assets.
- Spawn interactive shell without -c flag instead of broken "exec 0"
- Prevent infinite reconnect loop after manual disconnect
- Detect child process exit and notify client
- Fix binary output decoding with Uint8Array for bytes > 127
- Send initial resize on WebSocket connect
- Stabilize useEffect to avoid terminal reinit on re-render
- Include cwd in started message
- Move Terminal nav item between Comms and Network
- Set terminal height to 70% on screens wider than 1000px
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai bot commented Apr 8, 2026

Reviewer's Guide

Adds a WebSocket-powered interactive terminal feature exposed via the API and dashboard, backed by a portable PTY implementation, and performs small runtime fixes/refactors to support cloning runtime values and logging improvements.

Sequence diagram for the new WebSocket terminal session flow

sequenceDiagram
    actor User
    participant Dashboard
    participant TerminalPage
    participant BrowserWS as WebSocket
    participant ApiServer
    participant TerminalRoute
    participant WsHelpers
    participant PtySession
    participant ShellProcess

    User->>Dashboard: Click nav.terminal
    Dashboard->>TerminalPage: Render TerminalPage

    TerminalPage->>BrowserWS: new WebSocket(/api/terminal/ws)
    BrowserWS->>ApiServer: WS upgrade request
    ApiServer->>TerminalRoute: terminal_ws
    TerminalRoute->>ApiServer: Validate API tokens and sessions
    TerminalRoute->>WsHelpers: try_acquire_ws_slot(ip, max_ws_per_ip)
    WsHelpers-->>TerminalRoute: Option<WsConnectionGuard>
    alt slot available
        TerminalRoute->>BrowserWS: Accept upgrade
        BrowserWS->>TerminalRoute: WebSocket connection
        TerminalRoute->>TerminalRoute: handle_terminal_ws
    else slot unavailable
        TerminalRoute-->>BrowserWS: HTTP 429 TooManyRequests
    end

    loop on connection start
        TerminalRoute->>PtySession: PtySession::spawn()
        PtySession->>ShellProcess: Spawn interactive shell
        PtySession-->>TerminalRoute: (pty, pty_rx)
        TerminalRoute->>WsHelpers: send_json(sender, Started)
        WsHelpers-->>BrowserWS: {type: started, shell, pid, cwd}
    end

    loop PTY output
        PtySession-->>TerminalRoute: data via pty_rx
        TerminalRoute->>WsHelpers: send_json(Output or Output{binary:true})
        WsHelpers-->>BrowserWS: {type: output, data}
        BrowserWS-->>TerminalPage: message event
        TerminalPage->>TerminalPage: xterm.write(data)
    end

    loop User input
        User->>TerminalPage: Type in terminal
        TerminalPage->>BrowserWS: {type: input, data}
        BrowserWS->>TerminalRoute: Text message
        TerminalRoute->>TerminalRoute: Parse ClientMessage
        TerminalRoute->>PtySession: write(data)
    end

    loop Resize
        TerminalPage->>BrowserWS: {type: resize, cols, rows}
        BrowserWS->>TerminalRoute: Text message
        TerminalRoute->>PtySession: resize(cols, rows)
    end

    alt Client closes
        TerminalPage->>BrowserWS: {type: close}
        BrowserWS->>TerminalRoute: Text message
        TerminalRoute->>WsHelpers: send_json(Exit{code:0})
        WsHelpers-->>BrowserWS: {type: exit, code:0}
        BrowserWS-->>TerminalPage: close
    else Idle timeout
        TerminalRoute->>WsHelpers: send_json(Exit{code:124})
        WsHelpers-->>BrowserWS: {type: exit, code:124}
        TerminalRoute->>BrowserWS: Close frame
    else Shell exits
        PtySession-->>TerminalRoute: pty_rx ends
        TerminalRoute->>WsHelpers: send_json(Exit)
        WsHelpers-->>BrowserWS: {type: exit}
    end

    TerminalRoute->>WsHelpers: WsConnectionGuard dropped
    WsHelpers->>WsHelpers: Decrement connection count
Loading

Class diagram for terminal WebSocket route messages and helpers

classDiagram
    class ClientMessage {
        <<enum>>
        +validate() Result<(), String>
    }
    class ClientInput {
        +String data
        +Option<u64> timestamp
    }
    class ClientResize {
        +u16 cols
        +u16 rows
    }
    class ClientClose {
    }

    ClientMessage <|-- ClientInput
    ClientMessage <|-- ClientResize
    ClientMessage <|-- ClientClose

    class ServerMessage {
        <<enum>>
    }
    class ServerStarted {
        +String shell
        +u32 pid
        +Option<String> cwd
    }
    class ServerOutput {
        +String data
        +Option<bool> binary
    }
    class ServerExit {
        +u32 code
        +Option<String> signal
    }
    class ServerError {
        +String content
    }

    ServerMessage <|-- ServerStarted
    ServerMessage <|-- ServerOutput
    ServerMessage <|-- ServerExit
    ServerMessage <|-- ServerError

    class TerminalRouteModule {
        +MAX_WS_MSG_SIZE usize
        +terminal_health(state: Arc<AppState>) async
        +terminal_ws(ws: WebSocketUpgrade, state: Arc<AppState>, addr: SocketAddr, headers: HeaderMap, uri: Uri) async
        +handle_terminal_ws(socket: WebSocket, state: Arc<AppState>, client_ip: IpAddr, guard: WsConnectionGuard) async
    }

    class WsModuleExports {
        +ws_tracker() &'static DashMap<IpAddr, AtomicUsize>
        +try_acquire_ws_slot(ip: IpAddr, max_ws_per_ip: usize) Option<WsConnectionGuard>
        +send_json(sender: &Arc<Mutex<SplitSink<WebSocket, Message>>>, value: &serde_json::Value) async Result<(), axum::Error>
    }

    class PtySession {
        +pid u32
        +shell String
        +spawn() std::io::Result<(PtySession, mpsc::Receiver<Vec<u8>>)>$ 
    }

    TerminalRouteModule --> ClientMessage : parses
    TerminalRouteModule --> ServerMessage : sends
    TerminalRouteModule --> WsModuleExports : uses
    TerminalRouteModule --> PtySession : controls PTY session
    WsModuleExports ..> WsConnectionGuard : manages
Loading

Class diagram for the new dashboard TerminalPage and WebSocket interactions

classDiagram
    class TerminalPage {
        -HTMLDivElement containerRef
        -Terminal terminalRef
        -FitAddon fitAddonRef
        -WebSocket wsRef
        -Timeout reconnectTimeoutRef
        -bool intentionalDisconnect
        -bool isConnected
        -Option<String> error
        +TerminalPage()
        +connect() void
        +disconnect() void
    }

    class XtermTerminal {
        +open(container: HTMLDivElement) void
        +write(data: any) void
        +onData(handler: function) void
        +onResize(handler: function) void
        +dispose() void
    }

    class FitAddon {
        +fit() void
    }

    class ServerMessageTs {
        +"started" | "output" | "exit" | "error" type
        +String shell
        +number pid
        +String data
        +boolean binary
        +number code
        +String signal
        +String content
    }

    class ApiHelpers {
        +buildAuthenticatedWebSocketUrl(path: String) String$ 
    }

    TerminalPage --> XtermTerminal : creates
    TerminalPage --> FitAddon : uses
    TerminalPage --> ServerMessageTs : parses
    TerminalPage --> ApiHelpers : builds WS URL
    TerminalPage ..> WebSocket : communicates with API
Loading

File-Level Changes

Change Details Files
Add backend PTY abstraction and WebSocket terminal route with auth, rate limiting, and JSON protocol.
  • Introduce terminal PTY abstraction (PtySession) using portable_pty, with spawn, write, resize, and shell selection helpers.
  • Create /terminal/health and /terminal/ws routes that authenticate via API/session tokens, enforce per-IP WS limits, and manage PTY lifecycle over WebSocket using JSON messages.
  • Define ClientMessage and ServerMessage types with validation, size limits, and tests, plus JSON helper usage via the shared send_json function.
  • Expose ws tracking utilities (ws_tracker, WsConnectionGuard, try_acquire_ws_slot) and send_json publicly for reuse by the terminal route.
crates/librefang-api/src/terminal.rs
crates/librefang-api/src/routes/terminal.rs
crates/librefang-api/src/ws.rs
crates/librefang-api/src/routes/mod.rs
crates/librefang-api/src/server.rs
crates/librefang-api/src/lib.rs
crates/librefang-api/Cargo.toml
Cargo.lock
openapi.json
Add dashboard terminal page and navigation wiring that connects to the new WebSocket endpoint and renders an xterm.js terminal.
  • Add TerminalPage React component that initializes xterm.js with FitAddon, connects to /api/terminal/ws with auth, and handles reconnect, resize, input, and protocol messages.
  • Wire terminal route into TanStack router and navigation: add /terminal route, menu item, and command palette entry with localized strings.
  • Add xterm.js dependencies and lockfile updates plus locale entries for terminal-related labels and messages.
crates/librefang-api/dashboard/src/pages/TerminalPage.tsx
crates/librefang-api/dashboard/src/router.tsx
crates/librefang-api/dashboard/src/App.tsx
crates/librefang-api/dashboard/src/components/ui/CommandPalette.tsx
crates/librefang-api/dashboard/src/locales/en.json
crates/librefang-api/dashboard/src/locales/zh.json
crates/librefang-api/dashboard/package.json
crates/librefang-api/dashboard/package-lock.json
crates/librefang-api/dashboard/pnpm-lock.yaml
Add a full install just target that also installs a fresh dashboard and embeds CLI version metadata.
  • Introduce unix-only install-full just recipe that builds the CLI, installs the binary, wipes and copies dashboard assets, and writes librefang-cli version to the dashboard .version file.
justfile
Apply small runtime and logging fixes to support cloning runtimes and correct structured logging/borrowing.
  • Clone PluginRuntime where it is reused across async tasks or multiple calls (hook_templates, run_hook, prewarm).
  • Fix logging to use %runtime_tag for Display formatting and pass PluginRuntime::label() by &str when setting LIBREFANG_RUNTIME env var.
  • Minor style/formatting cleanups in plugin_manager and plugin_runtime for consistency.
crates/librefang-runtime/src/plugin_manager.rs
crates/librefang-runtime/src/context_engine.rs
crates/librefang-runtime/src/plugin_runtime.rs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions github-actions bot added the area/runtime Agent loop, LLM drivers, WASM sandbox label Apr 8, 2026
Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • In terminal_ws, the idle-timeout branch uses ws_idle_timeout.saturating_sub(last_activity.elapsed()) inside tokio::time::sleep, which becomes sleep(0) once the timeout is exceeded and can cause a busy loop; consider tracking a fixed deadline Instant and breaking when Instant::now() >= deadline instead of recalculating a saturated duration each iteration.
  • In TerminalPage.tsx, ws.onerror sets a hard-coded English string ("WebSocket connection error") instead of using the i18n translation keys used elsewhere; it would be more consistent to surface this via t("terminal.error_connection") (or a similar key) and reuse that in the subtitle/error text.
  • The shell_for_current_os function returns a (String, &str) pair but the flag part is unused by the PTY session (you always spawn an interactive shell with no args); either drop the flag from the API or start using it where appropriate to avoid dead/unnecessary values.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `terminal_ws`, the idle-timeout branch uses `ws_idle_timeout.saturating_sub(last_activity.elapsed())` inside `tokio::time::sleep`, which becomes `sleep(0)` once the timeout is exceeded and can cause a busy loop; consider tracking a fixed deadline `Instant` and breaking when `Instant::now() >= deadline` instead of recalculating a saturated duration each iteration.
- In `TerminalPage.tsx`, `ws.onerror` sets a hard-coded English string (`"WebSocket connection error"`) instead of using the i18n translation keys used elsewhere; it would be more consistent to surface this via `t("terminal.error_connection")` (or a similar key) and reuse that in the subtitle/error text.
- The `shell_for_current_os` function returns a `(String, &str)` pair but the flag part is unused by the PTY session (you always spawn an interactive shell with no args); either drop the flag from the API or start using it where appropriate to avoid dead/unnecessary values.

## Individual Comments

### Comment 1
<location path="crates/librefang-api/dashboard/src/pages/TerminalPage.tsx" line_range="99-100" />
<code_context>
+      }
+    };
+
+    ws.onerror = () => {
+      setError("WebSocket connection error");
+    };
+
</code_context>
<issue_to_address>
**suggestion:** WebSocket error text is hardcoded in English instead of going through i18n like the rest of the terminal UI.

This handler currently uses the hardcoded string `"WebSocket connection error"`. To keep the terminal UI fully localized and avoid mixing translated and untranslated text, route this through i18n, e.g. `setError(t("terminal.websocket_error"))`.

Suggested implementation:

```typescript
    ws.onerror = () => {
      setError(t("terminal.websocket_error"));
    };

```

Make sure that the `terminal.websocket_error` key exists in your i18n resource files (e.g. `en/translation.json`, etc.) with the appropriate localized strings. No other TypeScript changes should be necessary since `t` is already in scope and used elsewhere in this component.
</issue_to_address>

### Comment 2
<location path="crates/librefang-api/dashboard/src/pages/TerminalPage.tsx" line_range="103-109" />
<code_context>
+      setError("WebSocket connection error");
+    };
+
+    ws.onclose = () => {
+      setIsConnected(false);
+      if (intentionalDisconnectRef.current) {
+        intentionalDisconnectRef.current = false;
+        return;
+      }
+      reconnectTimeoutRef.current = setTimeout(() => {
+        if (
+          wsRef.current === null ||
</code_context>
<issue_to_address>
**suggestion (bug_risk):** The auto‑reconnect loop does not distinguish between transient failures and hard failures (e.g. auth issues), which could cause repeated failed reconnect attempts.

In `ws.onclose` a reconnect is always scheduled (when not intentional) after `RECONNECT_DELAY_MS`. If the server is actively rejecting connections (auth failure, rate limiting, etc.), the client will continue retrying indefinitely. Consider inspecting the `CloseEvent` (e.g. `event.code`) to detect non‑transient close reasons and either stop reconnecting or surface a clearer error instead of retrying forever.

Suggested implementation:

```typescript
    ws.onerror = () => {
      setError("WebSocket connection error");
    };

    ws.onclose = (event: CloseEvent) => {
      setIsConnected(false);

      if (intentionalDisconnectRef.current) {
        intentionalDisconnectRef.current = false;
        return;
      }

      // Treat certain close codes as non-transient and avoid infinite reconnect loops.
      // - 1008: Policy violation (often auth/authorization issues)
      // - 1011: Internal error
      // - 4xxx: Application-specific errors (e.g. auth, rate limiting)
      const isAppSpecificError = event.code >= 4000 && event.code <= 4999;
      const isNonTransientCloseCode = event.code === 1008 || event.code === 1011 || isAppSpecificError;

      if (isNonTransientCloseCode) {
        setError(
          event.reason ||
            t("terminal.connection_closed_non_recoverable")
        );
        return;
      }

      reconnectTimeoutRef.current = setTimeout(() => {
        if (
          wsRef.current === null ||
          wsRef.current.readyState === WebSocket.CLOSED
        ) {
          connect();
        }
      }, RECONNECT_DELAY_MS);
    };

```

1. Ensure that a translation key like `terminal.connection_closed_non_recoverable` exists in your i18n resources with a user-friendly message explaining that the connection was closed due to a non-recoverable error (e.g., auth failure or rate limiting).
2. If your project uses a custom WebSocket type or different typings, you may need to adjust `CloseEvent` to match your environment (e.g. from the DOM lib or a specific WebSocket implementation).
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +99 to +100
ws.onerror = () => {
setError("WebSocket connection error");
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: WebSocket error text is hardcoded in English instead of going through i18n like the rest of the terminal UI.

This handler currently uses the hardcoded string "WebSocket connection error". To keep the terminal UI fully localized and avoid mixing translated and untranslated text, route this through i18n, e.g. setError(t("terminal.websocket_error")).

Suggested implementation:

    ws.onerror = () => {
      setError(t("terminal.websocket_error"));
    };

Make sure that the terminal.websocket_error key exists in your i18n resource files (e.g. en/translation.json, etc.) with the appropriate localized strings. No other TypeScript changes should be necessary since t is already in scope and used elsewhere in this component.

Comment on lines +103 to +109
ws.onclose = () => {
setIsConnected(false);
if (intentionalDisconnectRef.current) {
intentionalDisconnectRef.current = false;
return;
}
reconnectTimeoutRef.current = setTimeout(() => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (bug_risk): The auto‑reconnect loop does not distinguish between transient failures and hard failures (e.g. auth issues), which could cause repeated failed reconnect attempts.

In ws.onclose a reconnect is always scheduled (when not intentional) after RECONNECT_DELAY_MS. If the server is actively rejecting connections (auth failure, rate limiting, etc.), the client will continue retrying indefinitely. Consider inspecting the CloseEvent (e.g. event.code) to detect non‑transient close reasons and either stop reconnecting or surface a clearer error instead of retrying forever.

Suggested implementation:

    ws.onerror = () => {
      setError("WebSocket connection error");
    };

    ws.onclose = (event: CloseEvent) => {
      setIsConnected(false);

      if (intentionalDisconnectRef.current) {
        intentionalDisconnectRef.current = false;
        return;
      }

      // Treat certain close codes as non-transient and avoid infinite reconnect loops.
      // - 1008: Policy violation (often auth/authorization issues)
      // - 1011: Internal error
      // - 4xxx: Application-specific errors (e.g. auth, rate limiting)
      const isAppSpecificError = event.code >= 4000 && event.code <= 4999;
      const isNonTransientCloseCode = event.code === 1008 || event.code === 1011 || isAppSpecificError;

      if (isNonTransientCloseCode) {
        setError(
          event.reason ||
            t("terminal.connection_closed_non_recoverable")
        );
        return;
      }

      reconnectTimeoutRef.current = setTimeout(() => {
        if (
          wsRef.current === null ||
          wsRef.current.readyState === WebSocket.CLOSED
        ) {
          connect();
        }
      }, RECONNECT_DELAY_MS);
    };
  1. Ensure that a translation key like terminal.connection_closed_non_recoverable exists in your i18n resources with a user-friendly message explaining that the connection was closed due to a non-recoverable error (e.g., auth failure or rate limiting).
  2. If your project uses a custom WebSocket type or different typings, you may need to adjust CloseEvent to match your environment (e.g. from the DOM lib or a specific WebSocket implementation).

@leszek3737 leszek3737 closed this Apr 8, 2026
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a new terminal feature, enabling real-time terminal sessions via WebSockets and PTY integration. The implementation includes a new backend route handler for terminal communication and a corresponding frontend page using xterm.js. My review highlights several areas for improvement, including performance concerns regarding session cleanup in the WebSocket handler, redundant validation logic, unnecessary memory allocations, and maintainability issues in the frontend cleanup logic.

Comment on lines +168 to +174
let mut sessions = state.active_sessions.write().await;
sessions.retain(|_, st| {
!crate::password_hash::is_token_expired(
st,
crate::password_hash::DEFAULT_SESSION_TTL_SECS,
)
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The WebSocket handler performs session cleanup (sessions.retain(...)) for every new connection attempt. This operation requires a write lock on the active_sessions map and iterates over all active sessions, which can become a significant performance bottleneck under load. This cleanup logic should be moved out of the hot path for handling new connections and into a periodic background task.

Comment on lines +79 to +81
} catch {
terminalRef.current?.write(msg.data);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The fallback in this catch block is not ideal. If atob() fails, it means the data received from the server is not valid Base64. Writing this raw data directly to the terminal will likely result in garbage output for the user. A better approach would be to log the error for debugging and display a clear error message in the terminal.

Suggested change
} catch {
terminalRef.current?.write(msg.data);
}
} catch (e) {
console.error("Failed to decode base64 data from server:", e);
terminalRef.current?.write("\r\n[Error: Corrupted binary data received from server]\r\n");
}

Comment on lines +176 to +189
return () => {
window.removeEventListener("resize", handleResize);
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
intentionalDisconnectRef.current = true;
wsRef.current.send(JSON.stringify({ type: "close" }));
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
term.dispose();
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The cleanup function in this useEffect hook duplicates most of the logic from the disconnect function. This can lead to maintenance issues where one is updated but the other is forgotten. For instance, the cleanup function is missing reconnectTimeoutRef.current = null; which is present in disconnect.

To improve maintainability and avoid code duplication, you should call the disconnect function from within the cleanup logic.

    return () => {
      window.removeEventListener("resize", handleResize);
      disconnect();
      term.dispose();
    };

#[serde(rename = "input")]
Input {
data: String,
timestamp: Option<u64>,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The timestamp field in ClientMessage::Input is being deserialized but it's not used anywhere in the handler. If this field is not planned for future use, it should be removed to simplify the data model and reduce unnecessary data transfer.

Comment on lines +83 to +89
const MAX_INPUT_SIZE: usize = 64 * 1024;
if data.len() > MAX_INPUT_SIZE {
return Err(format!(
"Input too large: {} bytes (max {MAX_INPUT_SIZE})",
data.len()
));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The size validation for ClientMessage::Input data is redundant. The handle_terminal_ws function already checks the total WebSocket message size against MAX_WS_MSG_SIZE. Since MAX_INPUT_SIZE is the same as MAX_WS_MSG_SIZE, and the JSON message itself has overhead, the check on data.len() will never fail. The outer check on the total message size is sufficient and this redundant check can be removed.

                // The check for input size is redundant and can be removed.
                // The overall message size is already checked in `handle_terminal_ws`.
                Ok(())

Comment on lines +247 to +260
let output_msg = match String::from_utf8(data.clone()) {
Ok(s) => serde_json::json!({
"type": "output",
"data": s
}),
Err(_) => {
use base64::Engine;
serde_json::json!({
"type": "output",
"data": base64::engine::general_purpose::STANDARD.encode(&data),
"binary": true
})
}
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The data vector is being cloned before being passed to String::from_utf8. This is an unnecessary allocation. You can avoid the clone by using the FromUtf8Error::into_bytes method to recover the original vector in case of a parsing failure.

            let output_msg = match String::from_utf8(data) {
                Ok(s) => serde_json::json!({
                    "type": "output",
                    "data": s
                }),
                Err(e) => {
                    use base64::Engine;
                    let data = e.into_bytes();
                    serde_json::json!({
                        "type": "output",
                        "data": base64::engine::general_purpose::STANDARD.encode(&data),
                        "binary": true
                    })
                }
            };

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/runtime Agent loop, LLM drivers, WASM sandbox

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants