From 1d36995c47490382789ec8610d439b2e0870f1ed Mon Sep 17 00:00:00 2001 From: kostazol Date: Sat, 23 May 2026 17:15:16 +0700 Subject: [PATCH] Fix(mobile) terminal replay, reset artifacts, and preview detection (#1383) * fix terminal rendering and preview detection * Fix bot comments * fix: protect terminal preview URL probe --------- Co-authored-by: Konstantin Zolin Co-authored-by: Bohdan Triapitsyn --- .../ui/src/components/views/TerminalView.tsx | 79 +++++++++++- packages/ui/src/lib/terminalApi.ts | 25 +++- packages/ui/src/lib/terminalPreview.ts | 115 ++++++++++++++++++ packages/ui/src/stores/useTerminalStore.ts | 67 ---------- .../web/server/lib/opencode/DOCUMENTATION.md | 1 + .../server/lib/opencode/bootstrap-runtime.js | 1 + .../web/server/lib/opencode/core-routes.js | 64 +++++++++- .../server/lib/opencode/core-routes.test.js | 46 ++++++- packages/web/server/lib/terminal/runtime.js | 28 +++-- 9 files changed, 337 insertions(+), 89 deletions(-) create mode 100644 packages/ui/src/lib/terminalPreview.ts diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index b86881ba..6f32237c 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -17,6 +17,7 @@ import { Icon } from "@/components/icon/Icon"; import { useDeviceInfo } from '@/lib/device'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { primeTerminalInputTransport } from '@/lib/terminalApi'; +import { extractTerminalPreviewUrl, isTerminalPreviewUrlAvailable } from '@/lib/terminalPreview'; import { useI18n } from '@/lib/i18n'; import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions'; @@ -113,6 +114,8 @@ export const TerminalView: React.FC = () => { const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle); const setConnecting = useTerminalStore((s) => s.setConnecting); const appendToBuffer = useTerminalStore((s) => s.appendToBuffer); + const setTabPreviewUrl = useTerminalStore((s) => s.setTabPreviewUrl); + const clearBuffer = useTerminalStore((s) => s.clearBuffer); const openContextPreview = useUIStore((state) => state.openContextPreview); @@ -162,6 +165,7 @@ export const TerminalView: React.FC = () => { const [isReconnectPending, setIsReconnectPending] = React.useState(false); const [activeModifier, setActiveModifier] = React.useState(null); const [isRestarting, setIsRestarting] = React.useState(false); + const [viewportSizeVersion, setViewportSizeVersion] = React.useState(0); const streamCleanupRef = React.useRef<(() => void) | null>(null); const activeTerminalIdRef = React.useRef(null); @@ -174,6 +178,15 @@ export const TerminalView: React.FC = () => { const nudgeOnConnectTerminalIdRef = React.useRef(null); const rehydratedTerminalIdsRef = React.useRef>(new Set()); const rehydratedSnapshotTakenRef = React.useRef(false); + const previewScanTailRef = React.useRef(''); + const pendingPreviewProbeUrlsRef = React.useRef>(new Set()); + const previewProbeGenerationRef = React.useRef(0); + + const resetTerminalPreviewScan = React.useCallback(() => { + previewScanTailRef.current = ''; + pendingPreviewProbeUrlsRef.current.clear(); + previewProbeGenerationRef.current += 1; + }, []); const focusTerminalWhenWindowActive = React.useCallback(() => { if (useTouchTerminalInput) { @@ -245,7 +258,8 @@ export const TerminalView: React.FC = () => { React.useEffect(() => { activeTabIdRef.current = activeTabId; - }, [activeTabId]); + resetTerminalPreviewScan(); + }, [activeTabId, resetTerminalPreviewScan]); React.useEffect(() => { directoryRef.current = effectiveDirectory; @@ -278,6 +292,47 @@ export const TerminalView: React.FC = () => { [disconnectStream] ); + const scanTerminalPreviewOutput = React.useCallback( + (directory: string, tabId: string, data: string) => { + if (!data) { + return; + } + + const combined = `${previewScanTailRef.current}${data}`.replace(/\r\n|\r/g, '\n'); + const lines = combined.split('\n'); + const completeText = combined.endsWith('\n') + ? lines.join('\n') + : lines.slice(0, -1).join('\n'); + previewScanTailRef.current = combined.endsWith('\n') ? '' : (lines[lines.length - 1] ?? '').slice(-1024); + + if (!completeText) { + return; + } + + const candidate = extractTerminalPreviewUrl(completeText); + if (!candidate || pendingPreviewProbeUrlsRef.current.has(candidate)) { + return; + } + + const probeGeneration = previewProbeGenerationRef.current; + pendingPreviewProbeUrlsRef.current.add(candidate); + void isTerminalPreviewUrlAvailable(candidate).then((available) => { + pendingPreviewProbeUrlsRef.current.delete(candidate); + if (!available || previewProbeGenerationRef.current !== probeGeneration) { + return; + } + + const currentTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((tab) => tab.id === tabId); + if (!currentTab || currentTab.previewUrlLocked || currentTab.previewUrl === candidate) { + return; + } + + setTabPreviewUrl(directory, tabId, candidate, { locked: false, autoOpened: false }); + }); + }, + [setTabPreviewUrl] + ); + const startStream = React.useCallback( ( directory: string, @@ -330,6 +385,7 @@ export const TerminalView: React.FC = () => { case 'data': { if (event.data) { appendToBuffer(directory, tabId, event.data); + scanTerminalPreviewOutput(directory, tabId, event.data); } break; } @@ -383,6 +439,7 @@ export const TerminalView: React.FC = () => { ); setIsFatalError(true); setConnecting(directory, tabId, false); + clearBuffer(directory, tabId); setTabLifecycle(directory, tabId, 'exited'); setTabSessionId(directory, tabId, null); disconnectStream(); @@ -398,8 +455,10 @@ export const TerminalView: React.FC = () => { }, [ appendToBuffer, + clearBuffer, disconnectStream, focusTerminalWhenWindowActive, + scanTerminalPreviewOutput, setConnecting, setTabLifecycle, setTabSessionId, @@ -469,12 +528,16 @@ export const TerminalView: React.FC = () => { return; } + const size = lastViewportSizeRef.current; + if (!size && isTerminalVisibleRef.current) { + return; + } + setConnectionError(null); setIsFatalError(false); setIsReconnectPending(false); setConnecting(directory, tabId, true); try { - const size = lastViewportSizeRef.current; const session = await terminal.createSession({ cwd: directory, cols: size?.cols, @@ -543,6 +606,7 @@ export const TerminalView: React.FC = () => { terminalLifecycle, activeTabId, hasOpenedTerminalViewport, + viewportSizeVersion, enableTabs, terminalHydrated, ensureDirectory, @@ -590,6 +654,8 @@ export const TerminalView: React.FC = () => { setIsReconnectPending(false); disconnectStream(); + clearBuffer(effectiveDirectory, tabId); + resetTerminalPreviewScan(); try { await closeTab(effectiveDirectory, tabId); @@ -602,7 +668,7 @@ export const TerminalView: React.FC = () => { } finally { setIsRestarting(false); } - }, [activeTabId, closeTab, disconnectStream, effectiveDirectory, enableTabs, isRestarting, t]); + }, [activeTabId, clearBuffer, closeTab, disconnectStream, effectiveDirectory, enableTabs, isRestarting, resetTerminalPreviewScan, t]); const handleHardRestart = React.useCallback(async () => { // Keep semantics: “close tab -> new clean tab”. @@ -692,7 +758,11 @@ export const TerminalView: React.FC = () => { const handleViewportResize = React.useCallback( (cols: number, rows: number) => { - lastViewportSizeRef.current = { cols, rows }; + const previous = lastViewportSizeRef.current; + if (!previous || previous.cols !== cols || previous.rows !== rows) { + lastViewportSizeRef.current = { cols, rows }; + setViewportSizeVersion((version) => version + 1); + } if (!isTerminalVisibleRef.current) { return; } @@ -1112,6 +1182,7 @@ export const TerminalView: React.FC = () => {
{shouldRenderViewport ? ( { terminalControllerRef.current = controller; }} diff --git a/packages/ui/src/lib/terminalApi.ts b/packages/ui/src/lib/terminalApi.ts index 99c38835..64c24f4d 100644 --- a/packages/ui/src/lib/terminalApi.ts +++ b/packages/ui/src/lib/terminalApi.ts @@ -48,7 +48,10 @@ type TerminalControlMessage = { t: string; s?: string; c?: string; + d?: string; f?: boolean; + i?: number; + r?: number; v?: number; exitCode?: number; signal?: number | null; @@ -152,6 +155,7 @@ class TerminalTransportManager { private closed = false; private subscriptions = new Map(); private activeSubscriptionToken: symbol | null = null; + private replayCursorBySession = new Map(); configure(socketUrl: string): void { if (!socketUrl) { @@ -233,7 +237,7 @@ class TerminalTransportManager { try { if (this.boundSessionId !== sessionId) { this.requestedSessionId = sessionId; - socket.send(encodeControlFrame({ t: 'b', s: sessionId, v: 2 })); + socket.send(encodeControlFrame({ t: 'b', s: sessionId, r: this.replayCursorBySession.get(sessionId) ?? 0, v: 2 })); } socket.send(data); return true; @@ -265,6 +269,7 @@ class TerminalTransportManager { this.socketUrl = ''; this.subscriptions.clear(); this.activeSubscriptionToken = null; + this.replayCursorBySession.clear(); } prime(): void { @@ -438,7 +443,7 @@ class TerminalTransportManager { this.requestedSessionId = activeSubscription.sessionId; try { - this.socket.send(encodeControlFrame({ t: 'b', s: activeSubscription.sessionId, v: 2 })); + this.socket.send(encodeControlFrame({ t: 'b', s: activeSubscription.sessionId, r: this.replayCursorBySession.get(activeSubscription.sessionId) ?? 0, v: 2 })); } catch { this.handleSocketFailure(new Error('Terminal websocket bind failed')); } @@ -569,6 +574,21 @@ class TerminalTransportManager { return; case 'po': return; + case 'd': { + const sessionId = payload.s ?? this.boundSessionId ?? this.requestedSessionId; + if (!activeSubscription || !sessionId || sessionId !== activeSubscription.sessionId) { + return; + } + + if (typeof payload.i === 'number' && Number.isFinite(payload.i)) { + this.replayCursorBySession.set(sessionId, Math.max(this.replayCursorBySession.get(sessionId) ?? 0, Math.trunc(payload.i))); + } + + if (typeof payload.d === 'string' && payload.d.length > 0) { + activeSubscription.onEvent({ type: 'data', data: payload.d }); + } + return; + } case 'bok': { this.boundSessionId = payload.s ?? this.requestedSessionId; if (!activeSubscription) { @@ -598,6 +618,7 @@ class TerminalTransportManager { this.clearConnectionTimeout(activeSubscription); this.boundSessionId = null; this.requestedSessionId = null; + this.replayCursorBySession.delete(activeSubscription.sessionId); activeSubscription.onEvent({ type: 'exit', exitCode: payload.exitCode, diff --git a/packages/ui/src/lib/terminalPreview.ts b/packages/ui/src/lib/terminalPreview.ts new file mode 100644 index 00000000..b0844956 --- /dev/null +++ b/packages/ui/src/lib/terminalPreview.ts @@ -0,0 +1,115 @@ +const ANSI_ESCAPE_PREFIX = String.fromCharCode(27); +const ANSI_ESCAPE_PATTERN = new RegExp(`${ANSI_ESCAPE_PREFIX}\\[[0-9;?]*[ -/]*[@-~]`, 'g'); +const LOOPBACK_URL_PATTERN = /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[(?:::1|::)\])(?::\d{2,5})?(?:\/[^\s<>'"`]*)?)/gi; +const PREVIEW_OUTPUT_PATTERN = /(?:➜\s*(?:Local|Network):)|\b(?:local|network|loopback|serving|listening|available|ready|started|running|server|vite|webpack|next\.js|astro|sveltekit|nuxt)\b/i; +const PYTHON_HTTP_SERVER_PATTERN = /Serving HTTP on .*? port (\d{2,5})/i; +const TRAILING_PUNCT = new Set(['.', ',', ';', ':', '!', '?']); + +const trimUrlTrailingPunctuation = (url: string): string => { + let result = url; + while (result.length > 0) { + const last = result[result.length - 1]; + if (last === ')' || last === ']' || last === '}' || last === '>') { + const opener = last === ')' ? '(' : last === ']' ? '[' : last === '}' ? '{' : '<'; + const head = result.slice(0, -1); + const opens = (head.match(new RegExp(`\\${opener}`, 'g')) || []).length; + const closes = (head.match(new RegExp(`\\${last}`, 'g')) || []).length; + if (opens > closes) break; + result = head; + continue; + } + if (TRAILING_PUNCT.has(last)) { + result = result.slice(0, -1); + continue; + } + break; + } + return result; +}; + +const normalizeLoopbackUrl = (url: string): string => { + let normalized = trimUrlTrailingPunctuation(url); + normalized = normalized.replace('0.0.0.0', '127.0.0.1'); + normalized = normalized.replace('[::1]', '127.0.0.1'); + normalized = normalized.replace('[::]', '127.0.0.1'); + return normalized; +}; + +export const extractTerminalPreviewUrl = (text: string): string | null => { + if (!text) return null; + + const cleaned = text.replace(ANSI_ESCAPE_PATTERN, ''); + const pythonMatch = cleaned.match(PYTHON_HTTP_SERVER_PATTERN); + if (pythonMatch?.[1]) { + const port = Number.parseInt(pythonMatch[1], 10); + if (Number.isFinite(port) && port > 0 && port <= 65535) { + return `http://127.0.0.1:${port}/`; + } + } + + const lines = cleaned.split('\n'); + for (const line of lines) { + if (!PREVIEW_OUTPUT_PATTERN.test(line)) { + continue; + } + + const matches = Array.from(line.matchAll(LOOPBACK_URL_PATTERN)); + if (matches.length === 0) { + continue; + } + + const withPort = matches.find((match) => { + try { + return Boolean(new URL(normalizeLoopbackUrl(match[1])).port); + } catch { + return false; + } + }); + return normalizeLoopbackUrl((withPort ?? matches[0])[1]); + } + + return null; +}; + +export const isTerminalPreviewUrlAvailable = async (url: string, timeoutMs = 1500): Promise => { + if (!url) return false; + if (typeof window === 'undefined') return false; + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false; + } + + const host = parsed.hostname.toLowerCase(); + if (host !== 'localhost' && host !== '127.0.0.1' && host !== '0.0.0.0' && host !== '::1' && host !== '::') { + return false; + } + + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch('/api/system/probe-url', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: parsed.toString() }), + cache: 'no-store', + signal: controller.signal, + }); + if (!response.ok) { + return false; + } + + const result = await response.json().catch(() => null) as { ok?: unknown } | null; + return result?.ok === true; + } catch { + return false; + } finally { + window.clearTimeout(timeout); + } +}; diff --git a/packages/ui/src/stores/useTerminalStore.ts b/packages/ui/src/stores/useTerminalStore.ts index 02a19e5c..68256f55 100644 --- a/packages/ui/src/stores/useTerminalStore.ts +++ b/packages/ui/src/stores/useTerminalStore.ts @@ -121,67 +121,6 @@ const createEmptyTab = (id: string, label: string): TerminalTab => ({ previewUrlLocked: false, }); -// eslint-disable-next-line no-control-regex -const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g; -// Many dev servers print loopback as 0.0.0.0, localhost, or IPv6 ([::]/[::1]). -const URL_PATTERN = /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[(?:::1|::)\])(?::\d{2,5})?(?:\/[\w\-./~%!$&'()*+,;=:@?#[\]]*)?)/i; - -// Dev server logs frequently wrap URLs in punctuation, e.g. -// "Local: http://localhost:5173/ (press h to show help)" -// "Serving on (http://127.0.0.1:50028/)." -// The URL_PATTERN above intentionally allows sub-delim characters like `()` -// in the path (RFC 3986), which means greedy capture can swallow trailing -// closing brackets that were really part of the surrounding sentence. -// Peel off any trailing closer that has no matching opener inside the URL, -// plus common trailing sentence punctuation. -const TRAILING_PUNCT = new Set(['.', ',', ';', ':', '!', '?']); -const trimUrlTrailingPunctuation = (url: string): string => { - let result = url; - while (result.length > 0) { - const last = result[result.length - 1]; - if (last === ')' || last === ']' || last === '}' || last === '>') { - const opener = last === ')' ? '(' : last === ']' ? '[' : last === '}' ? '{' : '<'; - // Count matched pairs in the rest of the URL; if there's no unmatched - // opener, the closer is from surrounding text — strip it. - const head = result.slice(0, -1); - const opens = (head.match(new RegExp(`\\${opener}`, 'g')) || []).length; - const closes = (head.match(new RegExp(`\\${last}`, 'g')) || []).length; - if (opens > closes) break; - result = head; - continue; - } - if (TRAILING_PUNCT.has(last)) { - result = result.slice(0, -1); - continue; - } - break; - } - return result; -}; - -const extractPreviewUrl = (chunk: string): string | null => { - if (!chunk) return null; - const cleaned = chunk.replace(ANSI_ESCAPE_PATTERN, ''); - const match = cleaned.match(URL_PATTERN); - if (!match?.[1]) return null; - let url = trimUrlTrailingPunctuation(match[1]); - // Normalize common loopback hostnames to a stable value so the iframe can load. - url = url.replace('0.0.0.0', '127.0.0.1'); - url = url.replace('[::1]', '127.0.0.1'); - url = url.replace('[::]', '127.0.0.1'); - return url; -}; - -const extractPythonHttpServerUrl = (chunk: string): string | null => { - if (!chunk) return null; - const cleaned = chunk.replace(ANSI_ESCAPE_PATTERN, ''); - const match = cleaned.match(/Serving HTTP on .*? port (\d{2,5})/i); - if (!match?.[1]) return null; - const port = Number.parseInt(match[1], 10); - if (!Number.isFinite(port) || port <= 0 || port > 65535) return null; - return `http://127.0.0.1:${port}/`; -}; - const createEmptyDirectoryState = (firstTab: TerminalTab): DirectoryTerminalState => ({ tabs: [firstTab], activeTabId: firstTab.id, @@ -527,17 +466,11 @@ export const useTerminalStore = create()( bufferLength -= removed.data.length; } - const maybePreviewUrl = tab.previewUrlLocked ? null : extractPreviewUrl(chunk) ?? extractPythonHttpServerUrl(chunk); - const shouldUpdatePreview = Boolean(maybePreviewUrl && maybePreviewUrl !== tab.previewUrl); - const nextTabs = [...existing.tabs]; nextTabs[idx] = { ...tab, bufferChunks, bufferLength, - ...(shouldUpdatePreview - ? { previewUrl: maybePreviewUrl, previewAutoOpened: false } - : null), }; newSessions.set(key, { ...existing, tabs: nextTabs }); diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 8b13ff4c..2e44890e 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -238,6 +238,7 @@ This module provides OpenCode server integration utilities for the web server ru - `DELETE /api/passkeys/:id` - `POST /api/auth/reset` - `GET /connect` + - `POST /api/system/probe-url` - `app.use('/api', ...)` auth/tunnel guard - `registerSettingsUtilityRoutes(app, dependencies)`: registers small settings utility endpoints: - `GET /api/config/themes` diff --git a/packages/web/server/lib/opencode/bootstrap-runtime.js b/packages/web/server/lib/opencode/bootstrap-runtime.js index 98a1f045..ede0c57f 100644 --- a/packages/web/server/lib/opencode/bootstrap-runtime.js +++ b/packages/web/server/lib/opencode/bootstrap-runtime.js @@ -71,6 +71,7 @@ export const createBootstrapRuntime = (dependencies) => { } registerAuthAndAccessRoutes(app, { + express, tunnelAuthController, uiAuthController, readSettingsFromDiskMigrated, diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index fe37638f..4b878f33 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -1,3 +1,27 @@ +const parseLoopbackUrl = (rawUrl) => { + if (typeof rawUrl !== 'string') { + return null; + } + + let url; + try { + url = new URL(rawUrl); + } catch { + return null; + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null; + } + + const host = url.hostname; + if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && host !== '0.0.0.0') { + return null; + } + + return url; +}; + export const registerServerStatusRoutes = (app, dependencies) => { const { express, @@ -239,16 +263,26 @@ export const registerServerStatusRoutes = (app, dependencies) => { return res.status(500).json({ error: (error && error.message) || 'Failed to allocate port' }); } }); + }; export const registerAuthAndAccessRoutes = (app, dependencies) => { const { + express, tunnelAuthController, uiAuthController, readSettingsFromDiskMigrated, normalizeTunnelSessionTtlMs, } = dependencies; + const requireApiAuth = async (req, res, next) => { + const requestScope = tunnelAuthController.classifyRequestScope(req); + if (requestScope === 'tunnel' || requestScope === 'unknown-public') { + return tunnelAuthController.requireTunnelSession(req, res, next); + } + return uiAuthController.requireAuth(req, res, next); + }; + app.get('/auth/session', async (req, res) => { const requestScope = tunnelAuthController.classifyRequestScope(req); if (requestScope === 'tunnel' || requestScope === 'unknown-public') { @@ -398,13 +432,33 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => { } }); + app.post('/api/system/probe-url', express.json({ limit: '16kb' }), async (req, res, next) => { + try { + await requireApiAuth(req, res, async () => { + const url = parseLoopbackUrl(req.body?.url); + if (!url) { + return res.status(400).json({ ok: false, error: 'Invalid loopback URL' }); + } + + try { + const response = await fetch(url.toString(), { + method: 'GET', + redirect: 'manual', + signal: AbortSignal.timeout(1500), + }); + return res.json({ ok: response.ok, status: response.status }); + } catch (error) { + return res.json({ ok: false, error: error?.message || 'Probe failed' }); + } + }); + } catch (error) { + next(error); + } + }); + app.use('/api', async (req, res, next) => { try { - const requestScope = tunnelAuthController.classifyRequestScope(req); - if (requestScope === 'tunnel' || requestScope === 'unknown-public') { - return tunnelAuthController.requireTunnelSession(req, res, next); - } - await uiAuthController.requireAuth(req, res, next); + await requireApiAuth(req, res, next); } catch (err) { next(err); } diff --git a/packages/web/server/lib/opencode/core-routes.test.js b/packages/web/server/lib/opencode/core-routes.test.js index d107b38d..86c39d75 100644 --- a/packages/web/server/lib/opencode/core-routes.test.js +++ b/packages/web/server/lib/opencode/core-routes.test.js @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import express from 'express'; import request from 'supertest'; -import { registerCommonRequestMiddleware, registerServerStatusRoutes } from './core-routes.js'; +import { registerAuthAndAccessRoutes, registerCommonRequestMiddleware, registerServerStatusRoutes } from './core-routes.js'; describe('core-routes', () => { it('should call gracefulShutdown with exitProcess: true on /api/system/shutdown', async () => { @@ -39,4 +39,48 @@ describe('core-routes', () => { expect(response.body).toEqual({ body: { content: 'Snippet body' } }); }); + + it('should require API auth before probing loopback preview URLs', async () => { + const app = express(); + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock; + + registerAuthAndAccessRoutes(app, { + express, + tunnelAuthController: { + classifyRequestScope: () => 'local', + requireTunnelSession: vi.fn(), + getTunnelSessionFromRequest: vi.fn(), + clearTunnelSessionCookie: vi.fn(), + exchangeBootstrapToken: vi.fn(), + }, + uiAuthController: { + requireAuth: (_req, res) => res.status(401).json({ error: 'Unauthorized' }), + handleSessionStatus: vi.fn(), + handleSessionCreate: vi.fn(), + handlePasskeyStatus: vi.fn(), + handlePasskeyAuthenticationOptions: vi.fn(), + handlePasskeyAuthenticationVerify: vi.fn(), + handlePasskeyRegistrationOptions: vi.fn(), + handlePasskeyRegistrationVerify: vi.fn(), + handlePasskeyList: vi.fn(), + handlePasskeyRevoke: vi.fn(), + handleResetAuth: vi.fn(), + }, + readSettingsFromDiskMigrated: vi.fn(async () => ({})), + normalizeTunnelSessionTtlMs: vi.fn(), + }); + + try { + await request(app) + .post('/api/system/probe-url') + .send({ url: 'http://127.0.0.1:5173/' }) + .expect(401); + + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); }); diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index 1c764985..2399bd74 100644 --- a/packages/web/server/lib/terminal/runtime.js +++ b/packages/web/server/lib/terminal/runtime.js @@ -230,6 +230,19 @@ export function createTerminalRuntime({ } }; + const sendTerminalOutputWsData = (socket, sessionId, replayChunk, data = replayChunk?.data) => { + if (!socket || socket.readyState !== 1 || !replayChunk) { + return false; + } + + try { + socket.send(createTerminalInputWsControlFrame({ t: 'd', s: sessionId, i: replayChunk.id, d: data }), { binary: true }); + return true; + } catch { + return false; + } + }; + let terminalInputWsServer = new WebSocketServer({ noServer: true, maxPayload: TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES, @@ -339,12 +352,11 @@ export function createTerminalRuntime({ const replayChunks = listTerminalOutputReplayChunksSince(targetSession.outputReplayBuffer, replaySince); for (const replayChunk of replayChunks) { - try { - socket.send(replayChunk.data); + if (sendTerminalOutputWsData(socket, nextSessionId, replayChunk)) { connectionState.replayCursorBySession.set(nextSessionId, replayChunk.id); - } catch { - break; + continue; } + break; } return; } @@ -444,12 +456,8 @@ export function createTerminalRuntime({ continue; } - try { - wsConnection.socket.send(data); - if (replayChunk) { - wsConnection.replayCursorBySession.set(sessionId, replayChunk.id); - } - } catch { + if (sendTerminalOutputWsData(wsConnection.socket, sessionId, replayChunk, data)) { + wsConnection.replayCursorBySession.set(sessionId, replayChunk.id); } } });