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 <zolin_ka@vk.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Konstantin Zolin
Bohdan Triapitsyn
parent
ca33c6ae57
commit
1d36995c47
@@ -17,6 +17,7 @@ import { Icon } from "@/components/icon/Icon";
|
|||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { primeTerminalInputTransport } from '@/lib/terminalApi';
|
import { primeTerminalInputTransport } from '@/lib/terminalApi';
|
||||||
|
import { extractTerminalPreviewUrl, isTerminalPreviewUrlAvailable } from '@/lib/terminalPreview';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions';
|
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 setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle);
|
||||||
const setConnecting = useTerminalStore((s) => s.setConnecting);
|
const setConnecting = useTerminalStore((s) => s.setConnecting);
|
||||||
const appendToBuffer = useTerminalStore((s) => s.appendToBuffer);
|
const appendToBuffer = useTerminalStore((s) => s.appendToBuffer);
|
||||||
|
const setTabPreviewUrl = useTerminalStore((s) => s.setTabPreviewUrl);
|
||||||
|
const clearBuffer = useTerminalStore((s) => s.clearBuffer);
|
||||||
|
|
||||||
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
||||||
|
|
||||||
@@ -162,6 +165,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
|
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
|
||||||
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
|
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
|
||||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||||
|
const [viewportSizeVersion, setViewportSizeVersion] = React.useState(0);
|
||||||
|
|
||||||
const streamCleanupRef = React.useRef<(() => void) | null>(null);
|
const streamCleanupRef = React.useRef<(() => void) | null>(null);
|
||||||
const activeTerminalIdRef = React.useRef<string | null>(null);
|
const activeTerminalIdRef = React.useRef<string | null>(null);
|
||||||
@@ -174,6 +178,15 @@ export const TerminalView: React.FC = () => {
|
|||||||
const nudgeOnConnectTerminalIdRef = React.useRef<string | null>(null);
|
const nudgeOnConnectTerminalIdRef = React.useRef<string | null>(null);
|
||||||
const rehydratedTerminalIdsRef = React.useRef<Set<string>>(new Set());
|
const rehydratedTerminalIdsRef = React.useRef<Set<string>>(new Set());
|
||||||
const rehydratedSnapshotTakenRef = React.useRef(false);
|
const rehydratedSnapshotTakenRef = React.useRef(false);
|
||||||
|
const previewScanTailRef = React.useRef('');
|
||||||
|
const pendingPreviewProbeUrlsRef = React.useRef<Set<string>>(new Set());
|
||||||
|
const previewProbeGenerationRef = React.useRef(0);
|
||||||
|
|
||||||
|
const resetTerminalPreviewScan = React.useCallback(() => {
|
||||||
|
previewScanTailRef.current = '';
|
||||||
|
pendingPreviewProbeUrlsRef.current.clear();
|
||||||
|
previewProbeGenerationRef.current += 1;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const focusTerminalWhenWindowActive = React.useCallback(() => {
|
const focusTerminalWhenWindowActive = React.useCallback(() => {
|
||||||
if (useTouchTerminalInput) {
|
if (useTouchTerminalInput) {
|
||||||
@@ -245,7 +258,8 @@ export const TerminalView: React.FC = () => {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
activeTabIdRef.current = activeTabId;
|
activeTabIdRef.current = activeTabId;
|
||||||
}, [activeTabId]);
|
resetTerminalPreviewScan();
|
||||||
|
}, [activeTabId, resetTerminalPreviewScan]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
directoryRef.current = effectiveDirectory;
|
directoryRef.current = effectiveDirectory;
|
||||||
@@ -278,6 +292,47 @@ export const TerminalView: React.FC = () => {
|
|||||||
[disconnectStream]
|
[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(
|
const startStream = React.useCallback(
|
||||||
(
|
(
|
||||||
directory: string,
|
directory: string,
|
||||||
@@ -330,6 +385,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
case 'data': {
|
case 'data': {
|
||||||
if (event.data) {
|
if (event.data) {
|
||||||
appendToBuffer(directory, tabId, event.data);
|
appendToBuffer(directory, tabId, event.data);
|
||||||
|
scanTerminalPreviewOutput(directory, tabId, event.data);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -383,6 +439,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
);
|
);
|
||||||
setIsFatalError(true);
|
setIsFatalError(true);
|
||||||
setConnecting(directory, tabId, false);
|
setConnecting(directory, tabId, false);
|
||||||
|
clearBuffer(directory, tabId);
|
||||||
setTabLifecycle(directory, tabId, 'exited');
|
setTabLifecycle(directory, tabId, 'exited');
|
||||||
setTabSessionId(directory, tabId, null);
|
setTabSessionId(directory, tabId, null);
|
||||||
disconnectStream();
|
disconnectStream();
|
||||||
@@ -398,8 +455,10 @@ export const TerminalView: React.FC = () => {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
appendToBuffer,
|
appendToBuffer,
|
||||||
|
clearBuffer,
|
||||||
disconnectStream,
|
disconnectStream,
|
||||||
focusTerminalWhenWindowActive,
|
focusTerminalWhenWindowActive,
|
||||||
|
scanTerminalPreviewOutput,
|
||||||
setConnecting,
|
setConnecting,
|
||||||
setTabLifecycle,
|
setTabLifecycle,
|
||||||
setTabSessionId,
|
setTabSessionId,
|
||||||
@@ -469,12 +528,16 @@ export const TerminalView: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const size = lastViewportSizeRef.current;
|
||||||
|
if (!size && isTerminalVisibleRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setConnectionError(null);
|
setConnectionError(null);
|
||||||
setIsFatalError(false);
|
setIsFatalError(false);
|
||||||
setIsReconnectPending(false);
|
setIsReconnectPending(false);
|
||||||
setConnecting(directory, tabId, true);
|
setConnecting(directory, tabId, true);
|
||||||
try {
|
try {
|
||||||
const size = lastViewportSizeRef.current;
|
|
||||||
const session = await terminal.createSession({
|
const session = await terminal.createSession({
|
||||||
cwd: directory,
|
cwd: directory,
|
||||||
cols: size?.cols,
|
cols: size?.cols,
|
||||||
@@ -543,6 +606,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
terminalLifecycle,
|
terminalLifecycle,
|
||||||
activeTabId,
|
activeTabId,
|
||||||
hasOpenedTerminalViewport,
|
hasOpenedTerminalViewport,
|
||||||
|
viewportSizeVersion,
|
||||||
enableTabs,
|
enableTabs,
|
||||||
terminalHydrated,
|
terminalHydrated,
|
||||||
ensureDirectory,
|
ensureDirectory,
|
||||||
@@ -590,6 +654,8 @@ export const TerminalView: React.FC = () => {
|
|||||||
setIsReconnectPending(false);
|
setIsReconnectPending(false);
|
||||||
|
|
||||||
disconnectStream();
|
disconnectStream();
|
||||||
|
clearBuffer(effectiveDirectory, tabId);
|
||||||
|
resetTerminalPreviewScan();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await closeTab(effectiveDirectory, tabId);
|
await closeTab(effectiveDirectory, tabId);
|
||||||
@@ -602,7 +668,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsRestarting(false);
|
setIsRestarting(false);
|
||||||
}
|
}
|
||||||
}, [activeTabId, closeTab, disconnectStream, effectiveDirectory, enableTabs, isRestarting, t]);
|
}, [activeTabId, clearBuffer, closeTab, disconnectStream, effectiveDirectory, enableTabs, isRestarting, resetTerminalPreviewScan, t]);
|
||||||
|
|
||||||
const handleHardRestart = React.useCallback(async () => {
|
const handleHardRestart = React.useCallback(async () => {
|
||||||
// Keep semantics: “close tab -> new clean tab”.
|
// Keep semantics: “close tab -> new clean tab”.
|
||||||
@@ -692,7 +758,11 @@ export const TerminalView: React.FC = () => {
|
|||||||
|
|
||||||
const handleViewportResize = React.useCallback(
|
const handleViewportResize = React.useCallback(
|
||||||
(cols: number, rows: number) => {
|
(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) {
|
if (!isTerminalVisibleRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1112,6 +1182,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
<div className="h-full w-full box-border pl-4 pr-1.5 pt-3 pb-4">
|
<div className="h-full w-full box-border pl-4 pr-1.5 pt-3 pb-4">
|
||||||
{shouldRenderViewport ? (
|
{shouldRenderViewport ? (
|
||||||
<TerminalViewport
|
<TerminalViewport
|
||||||
|
key={terminalViewportKey}
|
||||||
ref={(controller) => {
|
ref={(controller) => {
|
||||||
terminalControllerRef.current = controller;
|
terminalControllerRef.current = controller;
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ type TerminalControlMessage = {
|
|||||||
t: string;
|
t: string;
|
||||||
s?: string;
|
s?: string;
|
||||||
c?: string;
|
c?: string;
|
||||||
|
d?: string;
|
||||||
f?: boolean;
|
f?: boolean;
|
||||||
|
i?: number;
|
||||||
|
r?: number;
|
||||||
v?: number;
|
v?: number;
|
||||||
exitCode?: number;
|
exitCode?: number;
|
||||||
signal?: number | null;
|
signal?: number | null;
|
||||||
@@ -152,6 +155,7 @@ class TerminalTransportManager {
|
|||||||
private closed = false;
|
private closed = false;
|
||||||
private subscriptions = new Map<symbol, StreamSubscription>();
|
private subscriptions = new Map<symbol, StreamSubscription>();
|
||||||
private activeSubscriptionToken: symbol | null = null;
|
private activeSubscriptionToken: symbol | null = null;
|
||||||
|
private replayCursorBySession = new Map<string, number>();
|
||||||
|
|
||||||
configure(socketUrl: string): void {
|
configure(socketUrl: string): void {
|
||||||
if (!socketUrl) {
|
if (!socketUrl) {
|
||||||
@@ -233,7 +237,7 @@ class TerminalTransportManager {
|
|||||||
try {
|
try {
|
||||||
if (this.boundSessionId !== sessionId) {
|
if (this.boundSessionId !== sessionId) {
|
||||||
this.requestedSessionId = 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);
|
socket.send(data);
|
||||||
return true;
|
return true;
|
||||||
@@ -265,6 +269,7 @@ class TerminalTransportManager {
|
|||||||
this.socketUrl = '';
|
this.socketUrl = '';
|
||||||
this.subscriptions.clear();
|
this.subscriptions.clear();
|
||||||
this.activeSubscriptionToken = null;
|
this.activeSubscriptionToken = null;
|
||||||
|
this.replayCursorBySession.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
prime(): void {
|
prime(): void {
|
||||||
@@ -438,7 +443,7 @@ class TerminalTransportManager {
|
|||||||
this.requestedSessionId = activeSubscription.sessionId;
|
this.requestedSessionId = activeSubscription.sessionId;
|
||||||
|
|
||||||
try {
|
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 {
|
} catch {
|
||||||
this.handleSocketFailure(new Error('Terminal websocket bind failed'));
|
this.handleSocketFailure(new Error('Terminal websocket bind failed'));
|
||||||
}
|
}
|
||||||
@@ -569,6 +574,21 @@ class TerminalTransportManager {
|
|||||||
return;
|
return;
|
||||||
case 'po':
|
case 'po':
|
||||||
return;
|
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': {
|
case 'bok': {
|
||||||
this.boundSessionId = payload.s ?? this.requestedSessionId;
|
this.boundSessionId = payload.s ?? this.requestedSessionId;
|
||||||
if (!activeSubscription) {
|
if (!activeSubscription) {
|
||||||
@@ -598,6 +618,7 @@ class TerminalTransportManager {
|
|||||||
this.clearConnectionTimeout(activeSubscription);
|
this.clearConnectionTimeout(activeSubscription);
|
||||||
this.boundSessionId = null;
|
this.boundSessionId = null;
|
||||||
this.requestedSessionId = null;
|
this.requestedSessionId = null;
|
||||||
|
this.replayCursorBySession.delete(activeSubscription.sessionId);
|
||||||
activeSubscription.onEvent({
|
activeSubscription.onEvent({
|
||||||
type: 'exit',
|
type: 'exit',
|
||||||
exitCode: payload.exitCode,
|
exitCode: payload.exitCode,
|
||||||
|
|||||||
@@ -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<boolean> => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -121,67 +121,6 @@ const createEmptyTab = (id: string, label: string): TerminalTab => ({
|
|||||||
previewUrlLocked: false,
|
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 => ({
|
const createEmptyDirectoryState = (firstTab: TerminalTab): DirectoryTerminalState => ({
|
||||||
tabs: [firstTab],
|
tabs: [firstTab],
|
||||||
activeTabId: firstTab.id,
|
activeTabId: firstTab.id,
|
||||||
@@ -527,17 +466,11 @@ export const useTerminalStore = create<TerminalStore>()(
|
|||||||
bufferLength -= removed.data.length;
|
bufferLength -= removed.data.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maybePreviewUrl = tab.previewUrlLocked ? null : extractPreviewUrl(chunk) ?? extractPythonHttpServerUrl(chunk);
|
|
||||||
const shouldUpdatePreview = Boolean(maybePreviewUrl && maybePreviewUrl !== tab.previewUrl);
|
|
||||||
|
|
||||||
const nextTabs = [...existing.tabs];
|
const nextTabs = [...existing.tabs];
|
||||||
nextTabs[idx] = {
|
nextTabs[idx] = {
|
||||||
...tab,
|
...tab,
|
||||||
bufferChunks,
|
bufferChunks,
|
||||||
bufferLength,
|
bufferLength,
|
||||||
...(shouldUpdatePreview
|
|
||||||
? { previewUrl: maybePreviewUrl, previewAutoOpened: false }
|
|
||||||
: null),
|
|
||||||
};
|
};
|
||||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||||
|
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
|||||||
- `DELETE /api/passkeys/:id`
|
- `DELETE /api/passkeys/:id`
|
||||||
- `POST /api/auth/reset`
|
- `POST /api/auth/reset`
|
||||||
- `GET /connect`
|
- `GET /connect`
|
||||||
|
- `POST /api/system/probe-url`
|
||||||
- `app.use('/api', ...)` auth/tunnel guard
|
- `app.use('/api', ...)` auth/tunnel guard
|
||||||
- `registerSettingsUtilityRoutes(app, dependencies)`: registers small settings utility endpoints:
|
- `registerSettingsUtilityRoutes(app, dependencies)`: registers small settings utility endpoints:
|
||||||
- `GET /api/config/themes`
|
- `GET /api/config/themes`
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registerAuthAndAccessRoutes(app, {
|
registerAuthAndAccessRoutes(app, {
|
||||||
|
express,
|
||||||
tunnelAuthController,
|
tunnelAuthController,
|
||||||
uiAuthController,
|
uiAuthController,
|
||||||
readSettingsFromDiskMigrated,
|
readSettingsFromDiskMigrated,
|
||||||
|
|||||||
@@ -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) => {
|
export const registerServerStatusRoutes = (app, dependencies) => {
|
||||||
const {
|
const {
|
||||||
express,
|
express,
|
||||||
@@ -239,16 +263,26 @@ export const registerServerStatusRoutes = (app, dependencies) => {
|
|||||||
return res.status(500).json({ error: (error && error.message) || 'Failed to allocate port' });
|
return res.status(500).json({ error: (error && error.message) || 'Failed to allocate port' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||||
const {
|
const {
|
||||||
|
express,
|
||||||
tunnelAuthController,
|
tunnelAuthController,
|
||||||
uiAuthController,
|
uiAuthController,
|
||||||
readSettingsFromDiskMigrated,
|
readSettingsFromDiskMigrated,
|
||||||
normalizeTunnelSessionTtlMs,
|
normalizeTunnelSessionTtlMs,
|
||||||
} = dependencies;
|
} = 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) => {
|
app.get('/auth/session', async (req, res) => {
|
||||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
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) => {
|
app.use('/api', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
await requireApiAuth(req, res, next);
|
||||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
|
||||||
return tunnelAuthController.requireTunnelSession(req, res, next);
|
|
||||||
}
|
|
||||||
await uiAuthController.requireAuth(req, res, next);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { registerCommonRequestMiddleware, registerServerStatusRoutes } from './core-routes.js';
|
import { registerAuthAndAccessRoutes, registerCommonRequestMiddleware, registerServerStatusRoutes } from './core-routes.js';
|
||||||
|
|
||||||
describe('core-routes', () => {
|
describe('core-routes', () => {
|
||||||
it('should call gracefulShutdown with exitProcess: true on /api/system/shutdown', async () => {
|
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' } });
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
let terminalInputWsServer = new WebSocketServer({
|
||||||
noServer: true,
|
noServer: true,
|
||||||
maxPayload: TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
|
maxPayload: TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
|
||||||
@@ -339,12 +352,11 @@ export function createTerminalRuntime({
|
|||||||
|
|
||||||
const replayChunks = listTerminalOutputReplayChunksSince(targetSession.outputReplayBuffer, replaySince);
|
const replayChunks = listTerminalOutputReplayChunksSince(targetSession.outputReplayBuffer, replaySince);
|
||||||
for (const replayChunk of replayChunks) {
|
for (const replayChunk of replayChunks) {
|
||||||
try {
|
if (sendTerminalOutputWsData(socket, nextSessionId, replayChunk)) {
|
||||||
socket.send(replayChunk.data);
|
|
||||||
connectionState.replayCursorBySession.set(nextSessionId, replayChunk.id);
|
connectionState.replayCursorBySession.set(nextSessionId, replayChunk.id);
|
||||||
} catch {
|
continue;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -444,12 +456,8 @@ export function createTerminalRuntime({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (sendTerminalOutputWsData(wsConnection.socket, sessionId, replayChunk, data)) {
|
||||||
wsConnection.socket.send(data);
|
wsConnection.replayCursorBySession.set(sessionId, replayChunk.id);
|
||||||
if (replayChunk) {
|
|
||||||
wsConnection.replayCursorBySession.set(sessionId, replayChunk.id);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user