feat(terminal): interactive shell terminal — Phase 1#394
Conversation
- Server: node-pty based terminal manager (spawn, I/O, resize, cleanup) - Protocol: terminal_create/input/resize/destroy WS message schemas - Server: WS handlers wired into v2 dispatcher - Server: GET /api/terminals REST endpoint - Frontend: xterm.js Terminal component with mobile keyboard toolbar - Frontend: TerminalView page with PTY connection via useTerminal hook - Frontend: Route (/terminal, /terminal/:sessionId) + nav integration - Mobile: touch-friendly toolbar (Esc, Tab, Ctrl, arrows, pipe, slash, tilde) - Desktop: toolbar auto-hidden, Terminal in sidebar nav Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 10 issue(s) (3 critical) (5 warning).
frontend/src/hooks/useTerminal.ts
Critical build blocker (missing getWsChatUrl export), no terminal cleanup on disconnect (PTY leak), no resource limits, and no tests — the feature needs fixes before merge.
- 🔴 bugs (L11):
getWsChatUrlis imported from'../lib/api-fetch'but this function does not exist anywhere in the codebase. No file atfrontend/src/lib/api-fetch.tsexists, and no other file exports this symbol. This will fail at build time or runtime.[fixable] - 🟡 bugs (L59): The
connectcallback capturescolsandrowsin its closure (via the useCallback dependency array). If the xterm.js FitAddon resizes the terminal before the WebSocketwelcomearrives, theterminal_createmessage will send stale initial dimensions. Consider sending aterminal_resizeafterterminal_createdto sync.[fixable] - 🟡 unsafe_assumptions (L59): No reconnection logic. If the WebSocket drops, the terminal is lost with no way to resume. Other parts of the system (MitzoConnection) handle reconnection for chat — terminal connections should at minimum attempt reconnect or notify the user clearly.
[fixable]
server/terminal-manager.ts
Critical build blocker (missing getWsChatUrl export), no terminal cleanup on disconnect (PTY leak), no resource limits, and no tests — the feature needs fixes before merge.
- 🔴 unsafe_assumptions (L61): No limit on the number of terminals that can be created. An authenticated user can spawn unlimited PTY processes, exhausting server PIDs, memory, and file descriptors. Add a per-session and global terminal cap (e.g., 5 per session, 50 total).
[fixable] - 🔴 bugs (L158):
destroySessionTerminals()is defined but never called. When a WebSocket disconnects (index.ts close handler) or a session is closed (handleSessionClose / closeSessionByUser), terminals are not cleaned up. PTY processes will be orphaned and continue running indefinitely.[fixable] - 🟡 missing_tests: No tests for terminal-manager.ts. The project requires TDD per CLAUDE.md. At minimum, unit tests should cover: createTerminal, writeTerminal, resizeTerminal, destroyTerminal, destroySessionTerminals, listTerminals, and the callback wiring (setTerminalCallbacks/clearTerminalCallbacks). node-pty can be mocked.
[fixable]
server/ws-handler-v2.ts
Critical build blocker (missing getWsChatUrl export), no terminal cleanup on disconnect (PTY leak), no resource limits, and no tests — the feature needs fixes before merge.
- 🟡 unsafe_assumptions (L946): Terminal cwd is resolved via
sessionMeta?.cwd || BASE_REPO || process.cwd()withoutisAllowedPath()validation. The send handler (line 474) validates cwd withisAllowedPath()— terminal_create should do the same to prevent shells in unexpected directories.[fixable] - 🔵 style (L1056):
handleTerminalDestroylogs success even when the terminal was not found (the!okbranch sends an error but execution continues to the log.info on line 1056). Move the log inside a success branch.[fixable]
packages/protocol/src/ws-schemas-v2.ts
Critical build blocker (missing getWsChatUrl export), no terminal cleanup on disconnect (PTY leak), no resource limits, and no tests — the feature needs fixes before merge.
- 🟡 unsafe_assumptions (L134):
TerminalInputMessage.dataisz.string()with no max length. A malicious client could send arbitrarily large strings, causing memory pressure. Add.max()(e.g., 64KB) consistent with typical terminal input bounds.[fixable]
frontend/src/components/Terminal.tsx
Critical build blocker (missing getWsChatUrl export), no terminal cleanup on disconnect (PTY leak), no resource limits, and no tests — the feature needs fixes before merge.
- 🔵 style (L1): Multi-line doc comment block violates the project convention: 'default to writing no comments... never write multi-paragraph docstrings or multi-line comment blocks — one short line max.' Same applies to TerminalView.tsx (line 1) and useTerminal.ts (line 1) and terminal-manager.ts (line 1).
[fixable]
|
|
||
| import { useRef, useCallback, useEffect, useState } from 'react'; | ||
| import { getWsChatUrl } from '../lib/api-fetch'; | ||
|
|
There was a problem hiding this comment.
🔴 bugs: getWsChatUrl is imported from '../lib/api-fetch' but this function does not exist anywhere in the codebase. No file at frontend/src/lib/api-fetch.ts exists, and no other file exports this symbol. This will fail at build time or runtime. [fixable]
| } | ||
| }, []); | ||
|
|
||
| const connect = useCallback(() => { |
There was a problem hiding this comment.
🟡 bugs: The connect callback captures cols and rows in its closure (via the useCallback dependency array). If the xterm.js FitAddon resizes the terminal before the WebSocket welcome arrives, the terminal_create message will send stale initial dimensions. Consider sending a terminal_resize after terminal_created to sync. [fixable]
| } | ||
| }, []); | ||
|
|
||
| const connect = useCallback(() => { |
There was a problem hiding this comment.
🟡 unsafe_assumptions: No reconnection logic. If the WebSocket drops, the terminal is lost with no way to resume. Other parts of the system (MitzoConnection) handle reconnection for chat — terminal connections should at minimum attempt reconnect or notify the user clearly. [fixable]
| const rows = opts?.rows ?? 24; | ||
| const shell = getDefaultShell(); | ||
|
|
||
| const proc = pty.spawn(shell, [], { |
There was a problem hiding this comment.
🔴 unsafe_assumptions: No limit on the number of terminals that can be created. An authenticated user can spawn unlimited PTY processes, exhausting server PIDs, memory, and file descriptors. Add a per-session and global terminal cap (e.g., 5 per session, 50 total). [fixable]
| return result; | ||
| } | ||
|
|
||
| export function destroySessionTerminals(sessionId: string): number { |
There was a problem hiding this comment.
🔴 bugs: destroySessionTerminals() is defined but never called. When a WebSocket disconnects (index.ts close handler) or a session is closed (handleSessionClose / closeSessionByUser), terminals are not cleaned up. PTY processes will be orphaned and continue running indefinitely. [fixable]
|
|
||
| // Resolve cwd from session metadata (worktree path or base repo) | ||
| const sessionMeta = ctx.eventStore.getSession(msg.sessionId); | ||
| const cwd = sessionMeta?.cwd || BASE_REPO || process.cwd(); |
There was a problem hiding this comment.
🟡 unsafe_assumptions: Terminal cwd is resolved via sessionMeta?.cwd || BASE_REPO || process.cwd() without isAllowedPath() validation. The send handler (line 474) validates cwd with isAllowedPath() — terminal_create should do the same to prevent shells in unexpected directories. [fixable]
|
|
||
| // ─── Dispatcher ────────────────────────────────────────────────────────────── | ||
|
|
||
| /** |
There was a problem hiding this comment.
🔵 style: handleTerminalDestroy logs success even when the terminal was not found (the !ok branch sends an error but execution continues to the log.info on line 1056). Move the log inside a success branch. [fixable]
| rows: z.number().int().min(1).optional(), | ||
| }); | ||
|
|
||
| export const TerminalInputMessage = z.object({ |
There was a problem hiding this comment.
🟡 unsafe_assumptions: TerminalInputMessage.data is z.string() with no max length. A malicious client could send arbitrarily large strings, causing memory pressure. Add .max() (e.g., 64KB) consistent with typical terminal input bounds. [fixable]
| @@ -0,0 +1,165 @@ | |||
| /** | |||
There was a problem hiding this comment.
🔵 style: Multi-line doc comment block violates the project convention: 'default to writing no comments... never write multi-paragraph docstrings or multi-line comment blocks — one short line max.' Same applies to TerminalView.tsx (line 1) and useTerminal.ts (line 1) and terminal-manager.ts (line 1). [fixable]
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 10 issue(s) (2 critical) (5 warning).
server/ws-handler-v2.ts
The terminal feature works end-to-end but has two critical gaps: PTY processes are never cleaned up on disconnect (resource leak), and the spawned shell's cwd is not validated with isAllowedPath(). No tests exist, violating the project's mandatory TDD policy.
- 🔴 bugs (L940): Terminal PTY processes are never cleaned up when a WebSocket connection closes. The
ws.on('close')handler inindex.tsdetaches sessions but does not calldestroySessionTerminals(). Similarly,handleSessionClosedoes not clean up terminals. This means PTY processes leak indefinitely — they persist until the shell naturally exits or the server restarts. ThedestroySessionTerminals()function exists interminal-manager.tsbut is never imported or called anywhere.[fixable] - 🔴 unsafe_assumptions (L943): No path validation on the cwd used to spawn the PTY.
handleSendV2validates cwd withisAllowedPath(), buthandleTerminalCreatepassessessionMeta?.cwd || BASE_REPO || process.cwd()directly tocreateTerminal()without validation. If a session's stored cwd is somehow set to a path outside the allowed set (e.g.,/etc), the PTY spawns there. TheisAllowedPathcheck should be applied here too.[fixable] - 🟡 missing_tests: No test coverage for the four terminal WS handlers (
handleTerminalCreate,handleTerminalInput,handleTerminalResize,handleTerminalDestroy). Error paths (terminal not found, create failure) and the happy path should be tested.[fixable]
server/terminal-manager.ts
The terminal feature works end-to-end but has two critical gaps: PTY processes are never cleaned up on disconnect (resource leak), and the spawned shell's cwd is not validated with isAllowedPath(). No tests exist, violating the project's mandatory TDD policy.
- 🟡 unsafe_assumptions (L69): No limit on the number of terminals a client can create. A single authenticated user could call
terminal_createin a loop, spawning unlimited PTY processes and exhausting server resources. Consider adding a per-session or per-connection cap (e.g., max 5 terminals).[fixable] - 🟡 missing_tests: No test coverage for terminal-manager.ts. The CLAUDE.md mandates TDD for all feature work. Core logic like
createTerminal,destroyTerminal,destroySessionTerminals,listTerminals, and the callback wiring should have unit tests. The PTY can be mocked for deterministic testing.[fixable]
frontend/src/hooks/useTerminal.ts
The terminal feature works end-to-end but has two critical gaps: PTY processes are never cleaned up on disconnect (resource leak), and the spawned shell's cwd is not validated with isAllowedPath(). No tests exist, violating the project's mandatory TDD policy.
- 🟡 bugs (L62): The
connectcallback capturescolsandrowsin its closure (via useCallback deps), but these values are from the hook's initial options, not the terminal's actual dimensions after xterm.jsfit(). The Terminal component callsonResizeafter fitting, butconnectsends the stale default (80x24) in theterminal_createmessage. The actual fitted dimensions are only sent ifresizeis called later, creating a mismatch between the PTY and the rendered terminal until the first resize event.[fixable] - 🔵 style: The useTerminal hook creates a separate WebSocket connection instead of multiplexing on the existing shared connection (
MitzoConnection). The v2 protocol dispatcher already routes terminal messages, so a separate WS is redundant — it doubles connections per client and requires a duplicate hello/welcome handshake. Consider integrating with the existing@mitzo/clientconnection for consistency with the architecture described in CLAUDE.md.[fixable]
frontend/src/pages/TerminalView.tsx
The terminal feature works end-to-end but has two critical gaps: PTY processes are never cleaned up on disconnect (resource leak), and the spawned shell's cwd is not validated with isAllowedPath(). No tests exist, violating the project's mandatory TDD policy.
- 🟡 unsafe_assumptions (L18): When no
sessionIdURL param is provided, the code defaults to'default'. This string is passed toterminal_createwhich looks upeventStore.getSession('default')— unlikely to match any real session. The PTY will fall back toBASE_REPO || process.cwd(), which works but is not explicitly documented. The/terminalroute without a sessionId should either redirect to a session picker or clearly document this fallback behavior.[fixable]
frontend/src/App.tsx
The terminal feature works end-to-end but has two critical gaps: PTY processes are never cleaned up on disconnect (resource leak), and the spawned shell's cwd is not validated with isAllowedPath(). No tests exist, violating the project's mandatory TDD policy.
- 🔵 style (L172): The
/terminalroutes are not wrapped in<PageRoute>unlike all other protected pages (/inbox,/calendar,/todos,/tasks,/files). On desktop, the terminal view will not render inside theDesktopShelllayout, losing the nav sidebar. This may be intentional for a full-screen terminal experience, but is inconsistent with the other pages.[fixable]
frontend/src/components/Terminal.tsx
The terminal feature works end-to-end but has two critical gaps: PTY processes are never cleaned up on disconnect (resource leak), and the spawned shell's cwd is not validated with isAllowedPath(). No tests exist, violating the project's mandatory TDD policy.
- 🔵 style (L1): Multi-line file-level comment block (lines 1-7) violates the project convention: 'Default to writing no comments. Never write multi-paragraph docstrings or multi-line comment blocks — one short line max.' Same applies to the comment blocks in useTerminal.ts and TerminalView.tsx.
[fixable]
| withSpan( | ||
| 'ws.terminal_create', | ||
| { 'ws.connectionId': connectionId, 'ws.sessionId': msg.sessionId }, | ||
| () => { |
There was a problem hiding this comment.
🔴 bugs: Terminal PTY processes are never cleaned up when a WebSocket connection closes. The ws.on('close') handler in index.ts detaches sessions but does not call destroySessionTerminals(). Similarly, handleSessionClose does not clean up terminals. This means PTY processes leak indefinitely — they persist until the shell naturally exits or the server restarts. The destroySessionTerminals() function exists in terminal-manager.ts but is never imported or called anywhere. [fixable]
| () => { | ||
| const conn = ctx.connRegistry.get(connectionId); | ||
| if (!conn) return; | ||
|
|
There was a problem hiding this comment.
🔴 unsafe_assumptions: No path validation on the cwd used to spawn the PTY. handleSendV2 validates cwd with isAllowedPath(), but handleTerminalCreate passes sessionMeta?.cwd || BASE_REPO || process.cwd() directly to createTerminal() without validation. If a session's stored cwd is somehow set to a path outside the allowed set (e.g., /etc), the PTY spawns there. The isAllowedPath check should be applied here too. [fixable]
| env: { | ||
| ...process.env, | ||
| TERM: 'xterm-256color', | ||
| COLORTERM: 'truecolor', |
There was a problem hiding this comment.
🟡 unsafe_assumptions: No limit on the number of terminals a client can create. A single authenticated user could call terminal_create in a loop, spawning unlimited PTY processes and exhausting server resources. Consider adding a per-session or per-connection cap (e.g., max 5 terminals). [fixable]
| const connect = useCallback(() => { | ||
| if (wsRef.current?.readyState === WebSocket.OPEN) return; | ||
|
|
||
| const ws = new WebSocket(getWsChatUrl()); |
There was a problem hiding this comment.
🟡 bugs: The connect callback captures cols and rows in its closure (via useCallback deps), but these values are from the hook's initial options, not the terminal's actual dimensions after xterm.js fit(). The Terminal component calls onResize after fitting, but connect sends the stale default (80x24) in the terminal_create message. The actual fitted dimensions are only sent if resize is called later, creating a mismatch between the PTY and the rendered terminal until the first resize event. [fixable]
| const navigate = useNavigate(); | ||
| const termRef = useRef<{ write: (data: string) => void } | null>(null); | ||
|
|
||
| const resolvedSessionId = sessionId || 'default'; |
There was a problem hiding this comment.
🟡 unsafe_assumptions: When no sessionId URL param is provided, the code defaults to 'default'. This string is passed to terminal_create which looks up eventStore.getSession('default') — unlikely to match any real session. The PTY will fall back to BASE_REPO || process.cwd(), which works but is not explicitly documented. The /terminal route without a sessionId should either redirect to a session picker or clearly document this fallback behavior. [fixable]
| </ProtectedRoute> | ||
| } | ||
| /> | ||
| <Route |
There was a problem hiding this comment.
🔵 style: The /terminal routes are not wrapped in <PageRoute> unlike all other protected pages (/inbox, /calendar, /todos, /tasks, /files). On desktop, the terminal view will not render inside the DesktopShell layout, losing the nav sidebar. This may be intentional for a full-screen terminal experience, but is inconsistent with the other pages. [fixable]
| @@ -0,0 +1,165 @@ | |||
| /** | |||
There was a problem hiding this comment.
🔵 style: Multi-line file-level comment block (lines 1-7) violates the project convention: 'Default to writing no comments. Never write multi-paragraph docstrings or multi-line comment blocks — one short line max.' Same applies to the comment blocks in useTerminal.ts and TerminalView.tsx. [fixable]
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 10 issue(s) (2 critical) (6 warning).
server/terminal-manager.ts
The terminal feature works end-to-end but has critical gaps: PTY processes are never cleaned up on disconnect (orphan leak), terminal operations lack session ownership checks (any authed user can control any terminal), and the server's full environment is passed to spawned shells. No tests exist despite the project's strict TDD policy.
- 🔴 bugs (L158):
destroySessionTerminals()is exported but never called anywhere — not on session close, not on WS disconnect, not on server shutdown. When a WebSocket disconnects or a session is closed, the PTY processes remain alive as orphans, leaking processes and memory indefinitely. TheonDatacallback will also keep trying to send to a dead transport. EitherhandleSessionClosein ws-handler-v2.ts or the WS close handler in index.ts should calldestroySessionTerminals(sessionId).[fixable] - 🟡 unsafe_assumptions (L61): The spawned PTY inherits the entire server
process.envvia...process.env. This exposes sensitive server-side variables (API keys, JWT secrets, database credentials,ANTHROPIC_API_KEY, etc.) to the interactive shell. Consider using an explicit allowlist of safe environment variables or at minimum filtering out known secrets.[fixable] - 🟡 unsafe_assumptions (L51): No limit on the number of terminals a client can create. A single authenticated user can spam
terminal_createmessages and spawn unlimited PTY processes, exhausting server resources (PIDs, memory, file descriptors). Add a per-session or global terminal cap.[fixable] - 🟡 missing_tests: No tests for
terminal-manager.ts. The project follows strict TDD per CLAUDE.md — the test file should be the first artifact. At minimum,createTerminal,writeTerminal,resizeTerminal,destroyTerminal,destroySessionTerminals, and theonExitcleanup path need test coverage. Other server modules of comparable complexity (e.g.,task-store,worktree,auth) all have tests inserver/__tests__/.[fixable] - 🟡 bugs (L86): PTY
onDatafires immediately afterproc.onData()is registered, butmanaged.onDatais null untilsetTerminalCallbacks()is called later (after the WS response round-trip). Any output the shell produces between spawn and callback registration (e.g., shell init scripts, .zshrc output) is silently dropped. Consider buffering output until callbacks are attached.[fixable]
server/ws-handler-v2.ts
The terminal feature works end-to-end but has critical gaps: PTY processes are never cleaned up on disconnect (orphan leak), terminal operations lack session ownership checks (any authed user can control any terminal), and the server's full environment is passed to spawned shells. No tests exist despite the project's strict TDD policy.
- 🔴 unsafe_assumptions (L932):
handleTerminalCreatedoes not verify that the requesting connection owns the session. Any authenticated client can sendterminal_createwith an arbitrarysessionIdand get a shell in that session's worktree cwd. Compare withhandleSessionClosewhich checksgetOwnerConnection(found.clientId) !== connectionIdbefore acting. The same ownership check is missing fromhandleTerminalInput,handleTerminalResize, andhandleTerminalDestroy— any client can write to, resize, or kill any terminal by guessing the ID.[fixable] - 🟡 bugs (L948): The cwd fallback chain
sessionMeta?.cwd || BASE_REPO || process.cwd()falls through toprocess.cwd()when both are falsy.BASE_REPOisprocess.env.REPO_PATH || ''— an empty string is falsy, so ifREPO_PATHis unset and the session has no cwd, the terminal spawns in the server's working directory. This is likely fine in practice (REPO_PATH is required) but the empty-string case is a silent fallback that could surprise.[fixable]
frontend/src/pages/TerminalView.tsx
The terminal feature works end-to-end but has critical gaps: PTY processes are never cleaned up on disconnect (orphan leak), terminal operations lack session ownership checks (any authed user can control any terminal), and the server's full environment is passed to spawned shells. No tests exist despite the project's strict TDD policy.
- 🟡 bugs (L35): The
useEffectthat callsconnect()and returnsdestroy()depends on[connect, destroy]. Butconnectis memoized with[sessionId, cols, rows, send]— sincecols/rowsuse default values (80/24), any future change to these (e.g., from a resize) would recreateconnect, tearing down and reconnecting the WS. More immediately,destroycreates a new function identity each render becausesendis stable butdestroycapturessend— this is actually stable, but theconnect/destroycycle on mount/unmount could race if React strict-mode double-mounts.[fixable]
frontend/src/components/Terminal.tsx
The terminal feature works end-to-end but has critical gaps: PTY processes are never cleaned up on disconnect (orphan leak), terminal operations lack session ownership checks (any authed user can control any terminal), and the server's full environment is passed to spawned shells. No tests exist despite the project's strict TDD policy.
- 🔵 style (L1): Multi-line docstring comment block at the top of the file. CLAUDE.md style guide says: "Default to writing no comments" and "Never write multi-paragraph docstrings or multi-line comment blocks." Same applies to
useTerminal.ts(line 1),TerminalView.tsx(line 1), andterminal-manager.ts(line 1).[fixable]
frontend/src/hooks/useTerminal.ts
The terminal feature works end-to-end but has critical gaps: PTY processes are never cleaned up on disconnect (orphan leak), terminal operations lack session ownership checks (any authed user can control any terminal), and the server's full environment is passed to spawned shells. No tests exist despite the project's strict TDD policy.
- 🔵 unsafe_assumptions (L59): The terminal hook opens its own dedicated WebSocket connection rather than reusing the existing multiplexed
MitzoConnectionWS. This means each terminal view creates a separate authenticated WS, doubling the connection count. The comment says it "shares the same WS endpoint" but it doesn't share the connection — it's a second parallel WS. Consider using the existing multiplexed connection from@mitzo/client.
| return result; | ||
| } | ||
|
|
||
| export function destroySessionTerminals(sessionId: string): number { |
There was a problem hiding this comment.
🔴 bugs: destroySessionTerminals() is exported but never called anywhere — not on session close, not on WS disconnect, not on server shutdown. When a WebSocket disconnects or a session is closed, the PTY processes remain alive as orphans, leaking processes and memory indefinitely. The onData callback will also keep trying to send to a dead transport. Either handleSessionClose in ws-handler-v2.ts or the WS close handler in index.ts should call destroySessionTerminals(sessionId). [fixable]
| const rows = opts?.rows ?? 24; | ||
| const shell = getDefaultShell(); | ||
|
|
||
| const proc = pty.spawn(shell, [], { |
There was a problem hiding this comment.
🟡 unsafe_assumptions: The spawned PTY inherits the entire server process.env via ...process.env. This exposes sensitive server-side variables (API keys, JWT secrets, database credentials, ANTHROPIC_API_KEY, etc.) to the interactive shell. Consider using an explicit allowlist of safe environment variables or at minimum filtering out known secrets. [fixable]
| return process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh'); | ||
| } | ||
|
|
||
| export function createTerminal( |
There was a problem hiding this comment.
🟡 unsafe_assumptions: No limit on the number of terminals a client can create. A single authenticated user can spam terminal_create messages and spawn unlimited PTY processes, exhausting server resources (PIDs, memory, file descriptors). Add a per-session or global terminal cap. [fixable]
| onExit: null, | ||
| }; | ||
|
|
||
| proc.onData((data) => { |
There was a problem hiding this comment.
🟡 bugs: PTY onData fires immediately after proc.onData() is registered, but managed.onData is null until setTerminalCallbacks() is called later (after the WS response round-trip). Any output the shell produces between spawn and callback registration (e.g., shell init scripts, .zshrc output) is silently dropped. Consider buffering output until callbacks are attached. [fixable]
|
|
||
| // ─── Terminal handlers ────────────────────────────────────────────────────── | ||
|
|
||
| export function handleTerminalCreate( |
There was a problem hiding this comment.
🔴 unsafe_assumptions: handleTerminalCreate does not verify that the requesting connection owns the session. Any authenticated client can send terminal_create with an arbitrary sessionId and get a shell in that session's worktree cwd. Compare with handleSessionClose which checks getOwnerConnection(found.clientId) !== connectionId before acting. The same ownership check is missing from handleTerminalInput, handleTerminalResize, and handleTerminalDestroy — any client can write to, resize, or kill any terminal by guessing the ID. [fixable]
| const sessionMeta = ctx.eventStore.getSession(msg.sessionId); | ||
| const cwd = sessionMeta?.cwd || BASE_REPO || process.cwd(); | ||
|
|
||
| try { |
There was a problem hiding this comment.
🟡 bugs: The cwd fallback chain sessionMeta?.cwd || BASE_REPO || process.cwd() falls through to process.cwd() when both are falsy. BASE_REPO is process.env.REPO_PATH || '' — an empty string is falsy, so if REPO_PATH is unset and the session has no cwd, the terminal spawns in the server's working directory. This is likely fine in practice (REPO_PATH is required) but the empty-string case is a silent fallback that could surprise. [fixable]
|
|
||
| // Connect on mount | ||
| useEffect(() => { | ||
| connect(); |
There was a problem hiding this comment.
🟡 bugs: The useEffect that calls connect() and returns destroy() depends on [connect, destroy]. But connect is memoized with [sessionId, cols, rows, send] — since cols/rows use default values (80/24), any future change to these (e.g., from a resize) would recreate connect, tearing down and reconnecting the WS. More immediately, destroy creates a new function identity each render because send is stable but destroy captures send — this is actually stable, but the connect/destroy cycle on mount/unmount could race if React strict-mode double-mounts. [fixable]
| @@ -0,0 +1,165 @@ | |||
| /** | |||
There was a problem hiding this comment.
🔵 style: Multi-line docstring comment block at the top of the file. CLAUDE.md style guide says: "Default to writing no comments" and "Never write multi-paragraph docstrings or multi-line comment blocks." Same applies to useTerminal.ts (line 1), TerminalView.tsx (line 1), and terminal-manager.ts (line 1). [fixable]
| } | ||
| }, []); | ||
|
|
||
| const connect = useCallback(() => { |
There was a problem hiding this comment.
🔵 unsafe_assumptions: The terminal hook opens its own dedicated WebSocket connection rather than reusing the existing multiplexed MitzoConnection WS. This means each terminal view creates a separate authenticated WS, doubling the connection count. The comment says it "shares the same WS endpoint" but it doesn't share the connection — it's a second parallel WS. Consider using the existing multiplexed connection from @mitzo/client.
…bounds - Add per-session (5) and global (50) terminal caps in createTerminal() - Wire destroySessionTerminals() into WS close handler to prevent PTY leaks - Add isAllowedPath() validation on resolved terminal cwd - Add .max(65536) bound on TerminalInputMessage.data schema - Fix handleTerminalDestroy to only log on success - Fix stale cols/rows closure in useTerminal connect deps (use refs) - Generate unique fallback sessionId instead of 'default' - Trim multi-line JSDoc comments to single-line Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 9 issue(s) (3 critical) (5 warning).
server/terminal-manager.ts
Functional Phase 1 terminal implementation, but has critical security (server secrets leaked to PTY env) and resource management issues (orphaned PTYs on disconnect, no creation limits) that should be fixed before merge. Zero test coverage violates the project's mandatory TDD policy.
- 🔴 unsafe_assumptions (L67): The PTY inherits the full
process.env, including secrets likeAUTH_SECRET,AUTH_PASSPHRASE, andNTFY_AUTH_TOKEN. A user can runenvin the terminal to dump them. The chat.ts query loop explicitly deletes these vars before spawning the SDK — the same filtering must be applied here.[fixable] - 🔴 missing_tests: No test files exist for any of the terminal functionality (terminal-manager, ws-handler terminal dispatch, protocol schemas). The project's CLAUDE.md mandates TDD — tests must be the first artifact. At minimum: terminal-manager unit tests (create/write/resize/destroy/cleanup) and ws-handler integration tests for the four terminal message types.
[fixable] - 🟡 unsafe_assumptions: No limit on the number of terminals that can be created per session or globally. An authenticated client can spam
terminal_createto exhaust PIDs and file descriptors. Add a per-session cap (e.g. 5) and a global cap.[fixable] - 🟡 bugs (L93): Both
proc.onExit(line 93) anddestroyTerminal(line 123) callterminals.delete(id). WhendestroyTerminalcallsprocess.kill(), theonExithandler fires and invokesmanaged.onExit?.()— which sends aterminal_exitmessage to the client — then deletes the entry. ButdestroyTerminalalready deleted it on line 123. TheonExitcallback may also fire after the WS connection is gone, causing a write to a closed socket.[fixable]
server/index.ts
Functional Phase 1 terminal implementation, but has critical security (server secrets leaked to PTY env) and resource management issues (orphaned PTYs on disconnect, no creation limits) that should be fixed before merge. Zero test coverage violates the project's mandatory TDD policy.
- 🔴 bugs: When a WebSocket connection closes (line 461), watched sessions are detached but terminals are never cleaned up.
destroySessionTerminalsexists in terminal-manager.ts but is never imported or called anywhere. Orphaned PTY processes will accumulate indefinitely, leaking PIDs and file descriptors.[fixable]
frontend/src/hooks/useTerminal.ts
Functional Phase 1 terminal implementation, but has critical security (server secrets leaked to PTY env) and resource management issues (orphaned PTYs on disconnect, no creation limits) that should be fixed before merge. Zero test coverage violates the project's mandatory TDD policy.
- 🟡 bugs (L124): The
connectcallback depends on[sessionId, cols, rows, send]. Sincecols/rowsare props (default 80/24), if the parent ever passes dynamic values,connectis recreated, which triggers theuseEffectin TerminalView (line 34-37) to destroy-and-reconnect — killing the PTY and spawning a new one. The initial size should be captured once; subsequent changes should use theresizemessage instead.[fixable]
server/ws-handler-v2.ts
Functional Phase 1 terminal implementation, but has critical security (server secrets leaked to PTY env) and resource management issues (orphaned PTYs on disconnect, no creation limits) that should be fixed before merge. Zero test coverage violates the project's mandatory TDD policy.
- 🟡 bugs (L1051):
handleTerminalDestroyunconditionally logs 'terminal destroyed via ws' even whendestroyTerminalreturns false (terminal not found). The log line should be inside anif (ok)branch to avoid misleading log entries.[fixable]
frontend/src/pages/TerminalView.tsx
Functional Phase 1 terminal implementation, but has critical security (server secrets leaked to PTY env) and resource management issues (orphaned PTYs on disconnect, no creation limits) that should be fixed before merge. Zero test coverage violates the project's mandatory TDD policy.
- 🟡 unsafe_assumptions (L18):
resolvedSessionId = sessionId || 'default'— 'default' is not a real session ID, sogetSession('default')always returns null and the PTY spawns inBASE_REPO. This should either reject unknown sessions or be documented as intentional. Users visiting/terminalget an unscoped shell in the base repo.[fixable]
frontend/src/components/Terminal.tsx
Functional Phase 1 terminal implementation, but has critical security (server secrets leaked to PTY env) and resource management issues (orphaned PTYs on disconnect, no creation limits) that should be fixed before merge. Zero test coverage violates the project's mandatory TDD policy.
- 🔵 style (L1): Multi-line doc comment at top of file — project convention (CLAUDE.md) says 'default to writing no comments' and 'never write multi-paragraph docstrings or multi-line comment blocks'. Same applies to useTerminal.ts (line 1) and TerminalView.tsx (line 1).
[fixable]
| rows, | ||
| cwd, | ||
| env: { | ||
| ...process.env, |
There was a problem hiding this comment.
🔴 unsafe_assumptions: The PTY inherits the full process.env, including secrets like AUTH_SECRET, AUTH_PASSPHRASE, and NTFY_AUTH_TOKEN. A user can run env in the terminal to dump them. The chat.ts query loop explicitly deletes these vars before spawning the SDK — the same filtering must be applied here. [fixable]
| proc.onExit(({ exitCode, signal }) => { | ||
| log.info('terminal exited', { id, sessionId, exitCode, signal }); | ||
| managed.onExit?.(exitCode, signal); | ||
| terminals.delete(id); |
There was a problem hiding this comment.
🟡 bugs: Both proc.onExit (line 93) and destroyTerminal (line 123) call terminals.delete(id). When destroyTerminal calls process.kill(), the onExit handler fires and invokes managed.onExit?.() — which sends a terminal_exit message to the client — then deletes the entry. But destroyTerminal already deleted it on line 123. The onExit callback may also fire after the WS connection is gone, causing a write to a closed socket. [fixable]
| ws.onclose = () => { | ||
| setState((s) => ({ ...s, connected: false })); | ||
| }; | ||
| }, [sessionId, cols, rows, send]); |
There was a problem hiding this comment.
🟡 bugs: The connect callback depends on [sessionId, cols, rows, send]. Since cols/rows are props (default 80/24), if the parent ever passes dynamic values, connect is recreated, which triggers the useEffect in TerminalView (line 34-37) to destroy-and-reconnect — killing the PTY and spawning a new one. The initial size should be captured once; subsequent changes should use the resize message instead. [fixable]
| error: 'Terminal not found', | ||
| }); | ||
| } | ||
| log.info('terminal destroyed via ws', { connectionId, terminalId: msg.terminalId }); |
There was a problem hiding this comment.
🟡 bugs: handleTerminalDestroy unconditionally logs 'terminal destroyed via ws' even when destroyTerminal returns false (terminal not found). The log line should be inside an if (ok) branch to avoid misleading log entries. [fixable]
| const navigate = useNavigate(); | ||
| const termRef = useRef<{ write: (data: string) => void } | null>(null); | ||
|
|
||
| const resolvedSessionId = sessionId || 'default'; |
There was a problem hiding this comment.
🟡 unsafe_assumptions: resolvedSessionId = sessionId || 'default' — 'default' is not a real session ID, so getSession('default') always returns null and the PTY spawns in BASE_REPO. This should either reject unknown sessions or be documented as intentional. Users visiting /terminal get an unscoped shell in the base repo. [fixable]
| @@ -0,0 +1,165 @@ | |||
| /** | |||
There was a problem hiding this comment.
🔵 style: Multi-line doc comment at top of file — project convention (CLAUDE.md) says 'default to writing no comments' and 'never write multi-paragraph docstrings or multi-line comment blocks'. Same applies to useTerminal.ts (line 1) and TerminalView.tsx (line 1). [fixable]
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 9 issue(s) (3 critical) (4 warning).
server/index.ts
PTY terminal feature has a critical resource leak (orphaned terminals on WS close for standalone terminal connections), leaks all server env vars to the shell, and ships with zero tests despite the project's mandatory TDD policy. The core architecture (xterm.js + node-pty + WS relay) is sound, but needs cleanup tracking by connectionId, env var whitelisting, and comprehensive test coverage before merge.
- 🔴 bugs (L476): Terminal-only WS connections (from TerminalView) never call
watch/switch_session, sowatchedSessionsis empty at close time.destroySessionTerminals()is never called, leaking PTY processes indefinitely. The frontenduseTerminalhook opens a dedicated WS that only sendshello+terminal_create/input/resize/destroy— it never populateswatchedSessions. If the browser crashes or the WS closes unexpectedly (before the React cleanup effect firesterminal_destroy), the PTY process runs until it exits on its own or the server restarts.[fixable]
server/terminal-manager.ts
PTY terminal feature has a critical resource leak (orphaned terminals on WS close for standalone terminal connections), leaks all server env vars to the shell, and ships with zero tests despite the project's mandatory TDD policy. The core architecture (xterm.js + node-pty + WS relay) is sound, but needs cleanup tracking by connectionId, env var whitelisting, and comprehensive test coverage before merge.
- 🔴 unsafe_assumptions (L71): Spreading
...process.envinto the PTY child process leaks all server-side environment variables (API keys, JWT secrets, database credentials, ANTHROPIC_API_KEY, etc.) into the interactive shell. Any user with terminal access can runenvorprintenvto read them. Should whitelist safe variables (PATH, HOME, USER, LANG, SHELL, TERM, COLORTERM, EDITOR, LC_, XDG_) instead of inheriting everything.[fixable] - 🔴 missing_tests: No test file exists for terminal-manager.ts. The project mandates TDD (CLAUDE.md: 'All feature work follows TDD. This is not optional.'). Need tests for: createTerminal (limits, cwd, shell selection), writeTerminal/resizeTerminal (valid/invalid IDs), destroyTerminal, destroySessionTerminals, setTerminalCallbacks, and the global/per-session limit enforcement.
[fixable] - 🟡 bugs: No idle timeout or periodic sweep for orphaned terminals. Combined with the WS close cleanup bug, terminals created with synthetic sessionIds (e.g.
terminal-1719360000000from TerminalView) can accumulate without bound until the 50-terminal global limit is hit. Other session management (worktrees) has stale cleanup (96h TTL) — terminals should have similar protection.[fixable]
server/ws-handler-v2.ts
PTY terminal feature has a critical resource leak (orphaned terminals on WS close for standalone terminal connections), leaks all server env vars to the shell, and ships with zero tests despite the project's mandatory TDD policy. The core architecture (xterm.js + node-pty + WS relay) is sound, but needs cleanup tracking by connectionId, env var whitelisting, and comprehensive test coverage before merge.
- 🟡 missing_tests (L932): No test cases for the four terminal WS handlers (handleTerminalCreate/Input/Resize/Destroy) in the existing ws-handler-v2.test.ts (3400+ lines covering all other handlers). Missing coverage for: cwd resolution fallback, error paths, terminal-not-found responses, and callback wiring.
[fixable] - 🟡 unsafe_assumptions (L1006): handleTerminalInput/Resize/Destroy don't verify that the requesting connectionId owns the terminal. Any authenticated WS connection that knows a terminalId (predictable:
term-{timestamp}-{counter}) can write input to, resize, or destroy another connection's terminal. Other handlers (send, interrupt, close) check session ownership — terminals should too.[fixable]
packages/protocol/src/ws-schemas-v2.ts
PTY terminal feature has a critical resource leak (orphaned terminals on WS close for standalone terminal connections), leaks all server env vars to the shell, and ships with zero tests despite the project's mandatory TDD policy. The core architecture (xterm.js + node-pty + WS relay) is sound, but needs cleanup tracking by connectionId, env var whitelisting, and comprehensive test coverage before merge.
- 🟡 unsafe_assumptions (L130): cols/rows in TerminalCreateMessage and TerminalResizeMessage have no upper bound (only
.min(1)). A malicious client could send cols=999999, rows=999999 which node-pty would pass to the PTY subsystem. Add.max(500)or similar to prevent abuse.[fixable]
frontend/src/pages/TerminalView.tsx
PTY terminal feature has a critical resource leak (orphaned terminals on WS close for standalone terminal connections), leaks all server env vars to the shell, and ships with zero tests despite the project's mandatory TDD policy. The core architecture (xterm.js + node-pty + WS relay) is sound, but needs cleanup tracking by connectionId, env var whitelisting, and comprehensive test coverage before merge.
- 🔵 style (L13):
resolvedSessionId = sessionId || \terminal-${Date.now()}`generates a new ID on every render (not just mount), since it's not memoized. If the component re-renders before the effect runs, the sessionId could change. Wrap inuseMemooruseRefto stabilize it.[fixable]`
frontend/src/hooks/useTerminal.ts
PTY terminal feature has a critical resource leak (orphaned terminals on WS close for standalone terminal connections), leaks all server env vars to the shell, and ships with zero tests despite the project's mandatory TDD policy. The core architecture (xterm.js + node-pty + WS relay) is sound, but needs cleanup tracking by connectionId, env var whitelisting, and comprehensive test coverage before merge.
- 🔵 style (L62):
connectis in the dependency array of the mount effect in TerminalView, but it capturessessionIdin its closure. If sessionId changes,connectreference changes, triggering the effect cleanup (destroy) and re-run. This is likely fine given theuseMemosuggestion above, but the coupling is fragile.[fixable]
| @@ -473,6 +474,9 @@ function handleChatWsV2(ws: WebSocket, connectionId: string) { | |||
| log.info('v2 disconnected', { connectionId, code, reason: reason?.toString() }); | |||
|
|
|||
| for (const sessionId of watchedSessions) { | |||
There was a problem hiding this comment.
🔴 bugs: Terminal-only WS connections (from TerminalView) never call watch/switch_session, so watchedSessions is empty at close time. destroySessionTerminals() is never called, leaking PTY processes indefinitely. The frontend useTerminal hook opens a dedicated WS that only sends hello + terminal_create/input/resize/destroy — it never populates watchedSessions. If the browser crashes or the WS closes unexpectedly (before the React cleanup effect fires terminal_destroy), the PTY process runs until it exits on its own or the server restarts. [fixable]
| cols, | ||
| rows, | ||
| cwd, | ||
| env: { |
There was a problem hiding this comment.
🔴 unsafe_assumptions: Spreading ...process.env into the PTY child process leaks all server-side environment variables (API keys, JWT secrets, database credentials, ANTHROPIC_API_KEY, etc.) into the interactive shell. Any user with terminal access can run env or printenv to read them. Should whitelist safe variables (PATH, HOME, USER, LANG, SHELL, TERM, COLORTERM, EDITOR, LC_, XDG_) instead of inheriting everything. [fixable]
|
|
||
| // ─── Terminal handlers ────────────────────────────────────────────────────── | ||
|
|
||
| export function handleTerminalCreate( |
There was a problem hiding this comment.
🟡 missing_tests: No test cases for the four terminal WS handlers (handleTerminalCreate/Input/Resize/Destroy) in the existing ws-handler-v2.test.ts (3400+ lines covering all other handlers). Missing coverage for: cwd resolution fallback, error paths, terminal-not-found responses, and callback wiring. [fixable]
| ); | ||
| } | ||
|
|
||
| export function handleTerminalInput( |
There was a problem hiding this comment.
🟡 unsafe_assumptions: handleTerminalInput/Resize/Destroy don't verify that the requesting connectionId owns the terminal. Any authenticated WS connection that knows a terminalId (predictable: term-{timestamp}-{counter}) can write input to, resize, or destroy another connection's terminal. Other handlers (send, interrupt, close) check session ownership — terminals should too. [fixable]
| export const TerminalCreateMessage = z.object({ | ||
| type: z.literal('terminal_create'), | ||
| sessionId: z.string().min(1), | ||
| cols: z.number().int().min(1).optional(), |
There was a problem hiding this comment.
🟡 unsafe_assumptions: cols/rows in TerminalCreateMessage and TerminalResizeMessage have no upper bound (only .min(1)). A malicious client could send cols=999999, rows=999999 which node-pty would pass to the PTY subsystem. Add .max(500) or similar to prevent abuse. [fixable]
| const navigate = useNavigate(); | ||
| const termRef = useRef<{ write: (data: string) => void } | null>(null); | ||
|
|
||
| const resolvedSessionId = sessionId || `terminal-${Date.now()}`; |
There was a problem hiding this comment.
🔵 style: resolvedSessionId = sessionId || \terminal-${Date.now()}`generates a new ID on every render (not just mount), since it's not memoized. If the component re-renders before the effect runs, the sessionId could change. Wrap inuseMemooruseRefto stabilize it.[fixable]`
|
|
||
| const ws = new WebSocket(getWsChatUrl()); | ||
| wsRef.current = ws; | ||
|
|
There was a problem hiding this comment.
🔵 style: connect is in the dependency array of the mount effect in TerminalView, but it captures sessionId in its closure. If sessionId changes, connect reference changes, triggering the effect cleanup (destroy) and re-run. This is likely fine given the useMemo suggestion above, but the coupling is fragile. [fixable]
- Track terminals by connectionId for cleanup on WS disconnect - Add destroyConnectionTerminals() — fixes PTY leak for terminal-only WS - Whitelist safe env vars (PATH, HOME, etc.) instead of inheriting all - Add ownership verification on terminal input/resize/destroy operations - Add .max(500) bound on cols/rows in create and resize schemas - Stabilize fallback sessionId with useMemo to prevent re-render churn Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 8 issue(s) (5 warning).
frontend/src/hooks/useTerminal.ts
Solid Phase 1 implementation with good security fundamentals (env sanitization, ownership checks, auth). Key issues: missing TDD tests (project requirement), PTY output race between creation and callback wiring, WS connect guard doesn't prevent double-connect in CONNECTING state, and destroy functions need try-catch around kill().
- 🟡 bugs (L58): connect() only guards against
readyState === OPEN, but notCONNECTING. If called twice in quick succession (e.g., React strict mode double-mount in dev, or rapid navigation), a second WebSocket is created and overwriteswsRef.currentwhile the first is still connecting — orphaning the first WS with dangling event handlers. Guard should beif (wsRef.current?.readyState === WebSocket.OPEN || wsRef.current?.readyState === WebSocket.CONNECTING) return;.[fixable] - 🔵 bugs (L79): In the
welcomehandler,send()is called to create the terminal. Butsend()reads fromwsRef.current, which was assigned on line 61. Theonopenhandler on line 63 usesws.send()directly (correct), while thewelcomehandler goes through thesendwrapper. Ifconnect()were called again beforewelcomearrives,wsRef.currentcould point to a different WS. Usingws.send()directly (capturing the localwsvariable) would be more robust.[fixable]
server/terminal-manager.ts
Solid Phase 1 implementation with good security fundamentals (env sanitization, ownership checks, auth). Key issues: missing TDD tests (project requirement), PTY output race between creation and callback wiring, WS connect guard doesn't prevent double-connect in CONNECTING state, and destroy functions need try-catch around kill().
- 🟡 bugs (L156):
process.kill()is not wrapped in try-catch. If the process has already been reaped, kill() can throw, leaving the terminal in the map as a zombie and — indestroySessionTerminals/destroyConnectionTerminals— skipping cleanup of subsequent terminals in the loop. Wrap in try-catch across all three destroy functions (lines 156, 197, 212).[fixable] - 🟡 missing_tests: No test files exist for terminal-manager.ts (the core PTY lifecycle module) or the terminal WS handlers in ws-handler-v2.ts. The project mandates TDD ('All feature work follows TDD. This is not optional.'). At minimum: createTerminal/destroyTerminal lifecycle, limit enforcement, ownership verification, buildSafeEnv filtering, and the WS handler dispatch paths should be tested.
[fixable] - 🟡 unsafe_assumptions (L121): PTY output callbacks are wired in
setTerminalCallbacks(called from ws-handler-v2.ts after createTerminal), butproc.onDatafires immediately after spawn. Any output produced betweencreateTerminal(line 131) andsetTerminalCallbacks(line 957 in ws-handler-v2.ts) is silently dropped becausemanaged.onDatais still null. This includes the shell prompt and any login messages. Consider accepting callbacks increateTerminaloptions to avoid the race.[fixable]
server/ws-handler-v2.ts
Solid Phase 1 implementation with good security fundamentals (env sanitization, ownership checks, auth). Key issues: missing TDD tests (project requirement), PTY output race between creation and callback wiring, WS connect guard doesn't prevent double-connect in CONNECTING state, and destroy functions need try-catch around kill().
- 🟡 unsafe_assumptions (L948): When
BASE_REPOis empty (REPO_PATH unset) andsessionMeta?.cwdis also absent, the fallback chain resolves toprocess.cwd()— the server's own working directory. This silently allows terminal creation in an unintended location. The fallback on line 948 (isAllowedPath(rawCwd) ? rawCwd : BASE_REPO || process.cwd()) has the same issue: ifisAllowedPathrejectsrawCwdandBASE_REPOis falsy, it falls back toprocess.cwd()again. Consider throwing a terminal_error instead of using an unchecked fallback.[fixable]
frontend/src/pages/TerminalView.tsx
Solid Phase 1 implementation with good security fundamentals (env sanitization, ownership checks, auth). Key issues: missing TDD tests (project requirement), PTY output race between creation and callback wiring, WS connect guard doesn't prevent double-connect in CONNECTING state, and destroy functions need try-catch around kill().
- 🔵 style (L13):
terminal-${Date.now()}as a sessionId is opaque and non-deterministic. Since this ID is sent to the server and used for terminal grouping/limit counting, a UUID (viacrypto.randomUUID()) would avoid millisecond-collision edge cases and be consistent with how other IDs are generated in the codebase (connectionId usesrandomUUID).[fixable]
frontend/src/components/Terminal.tsx
Solid Phase 1 implementation with good security fundamentals (env sanitization, ownership checks, auth). Key issues: missing TDD tests (project requirement), PTY output race between creation and callback wiring, WS connect guard doesn't prevent double-connect in CONNECTING state, and destroy functions need try-catch around kill().
- 🔵 style (L103): The
eslint-disable-next-line react-hooks/exhaustive-depssuppression on the empty-deps useEffect is acceptable here since the intent is mount-once behavior and all callbacks are stable. However, this could be made explicit by extracting the xterm setup into a custom hook or documenting which deps are intentionally omitted.
| }, []); | ||
|
|
||
| const connect = useCallback(() => { | ||
| if (wsRef.current?.readyState === WebSocket.OPEN) return; |
There was a problem hiding this comment.
🟡 bugs: connect() only guards against readyState === OPEN, but not CONNECTING. If called twice in quick succession (e.g., React strict mode double-mount in dev, or rapid navigation), a second WebSocket is created and overwrites wsRef.current while the first is still connecting — orphaning the first WS with dangling event handlers. Guard should be if (wsRef.current?.readyState === WebSocket.OPEN || wsRef.current?.readyState === WebSocket.CONNECTING) return;. [fixable]
| switch (msg.type) { | ||
| case 'welcome': | ||
| // Handshake complete — create terminal | ||
| send({ |
There was a problem hiding this comment.
🔵 bugs: In the welcome handler, send() is called to create the terminal. But send() reads from wsRef.current, which was assigned on line 61. The onopen handler on line 63 uses ws.send() directly (correct), while the welcome handler goes through the send wrapper. If connect() were called again before welcome arrives, wsRef.current could point to a different WS. Using ws.send() directly (capturing the local ws variable) would be more robust. [fixable]
| export function destroyTerminal(id: string): boolean { | ||
| const term = terminals.get(id); | ||
| if (!term) return false; | ||
| term.process.kill(); |
There was a problem hiding this comment.
🟡 bugs: process.kill() is not wrapped in try-catch. If the process has already been reaped, kill() can throw, leaving the terminal in the map as a zombie and — in destroySessionTerminals/destroyConnectionTerminals — skipping cleanup of subsequent terminals in the loop. Wrap in try-catch across all three destroy functions (lines 156, 197, 212). [fixable]
| onExit: null, | ||
| }; | ||
|
|
||
| proc.onData((data) => { |
There was a problem hiding this comment.
🟡 unsafe_assumptions: PTY output callbacks are wired in setTerminalCallbacks (called from ws-handler-v2.ts after createTerminal), but proc.onData fires immediately after spawn. Any output produced between createTerminal (line 131) and setTerminalCallbacks (line 957 in ws-handler-v2.ts) is silently dropped because managed.onData is still null. This includes the shell prompt and any login messages. Consider accepting callbacks in createTerminal options to avoid the race. [fixable]
| // Resolve cwd from session metadata (worktree path or base repo) | ||
| const sessionMeta = ctx.eventStore.getSession(msg.sessionId); | ||
| const rawCwd = sessionMeta?.cwd || BASE_REPO || process.cwd(); | ||
| const cwd = isAllowedPath(rawCwd) ? rawCwd : BASE_REPO || process.cwd(); |
There was a problem hiding this comment.
🟡 unsafe_assumptions: When BASE_REPO is empty (REPO_PATH unset) and sessionMeta?.cwd is also absent, the fallback chain resolves to process.cwd() — the server's own working directory. This silently allows terminal creation in an unintended location. The fallback on line 948 (isAllowedPath(rawCwd) ? rawCwd : BASE_REPO || process.cwd()) has the same issue: if isAllowedPath rejects rawCwd and BASE_REPO is falsy, it falls back to process.cwd() again. Consider throwing a terminal_error instead of using an unchecked fallback. [fixable]
| const navigate = useNavigate(); | ||
| const termRef = useRef<{ write: (data: string) => void } | null>(null); | ||
|
|
||
| const resolvedSessionId = useMemo(() => sessionId || `terminal-${Date.now()}`, [sessionId]); |
There was a problem hiding this comment.
🔵 style: terminal-${Date.now()} as a sessionId is opaque and non-deterministic. Since this ID is sent to the server and used for terminal grouping/limit counting, a UUID (via crypto.randomUUID()) would avoid millisecond-collision edge cases and be consistent with how other IDs are generated in the codebase (connectionId uses randomUUID). [fixable]
| termRef.current = null; | ||
| xterm.dispose(); | ||
| }; | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps |
There was a problem hiding this comment.
🔵 style: The eslint-disable-next-line react-hooks/exhaustive-deps suppression on the empty-deps useEffect is acceptable here since the intent is mount-once behavior and all callbacks are stable. However, this could be made explicit by extracting the xterm setup into a custom hook or documenting which deps are intentionally omitted.
Summary
server/terminal-manager.ts) — spawn, I/O, resize, cleanup via node-ptyterminal_create/input/resize/destroy) with Zod schemas, wired into v2 dispatcherTerminal.tsxcomponent with mobile keyboard toolbar (Esc, Tab, Ctrl, arrows, pipe, slash, tilde)useTerminalhook managing WS lifecycle/terminaland/terminal/:sessionIdGET /api/terminalsfor listing active terminalsArchitecture
Terminal I/O uses a dedicated WS connection (same
/ws/chatendpoint, v2 protocol) independent of the chat connection. Each terminal spawns a node-pty process scoped to the session's worktree cwd. The mobile toolbar is hidden on desktop (real keyboard available).Phases
Test plan
🤖 Generated with Claude Code