Protect stdout and stderr so JavaScript code can't accidentally write to stdout corrupting ink rendering (#13247)

Bypassing rules as link checker failure is spurious.
This commit is contained in:
Jacob Richman
2025-11-20 10:44:02 -08:00
committed by GitHub
parent e20d282088
commit d1e35f8660
82 changed files with 1523 additions and 868 deletions
@@ -5,6 +5,7 @@
*/
import { useEffect } from 'react';
import { writeToStdout } from '../../utils/stdio.js';
const ENABLE_BRACKETED_PASTE = '\x1b[?2004h';
const DISABLE_BRACKETED_PASTE = '\x1b[?2004l';
@@ -17,11 +18,11 @@ const DISABLE_BRACKETED_PASTE = '\x1b[?2004l';
*/
export const useBracketedPaste = () => {
const cleanup = () => {
process.stdout.write(DISABLE_BRACKETED_PASTE);
writeToStdout(DISABLE_BRACKETED_PASTE);
};
useEffect(() => {
process.stdout.write(ENABLE_BRACKETED_PASTE);
writeToStdout(ENABLE_BRACKETED_PASTE);
process.on('exit', cleanup);
process.on('SIGINT', cleanup);
@@ -14,7 +14,7 @@ import {
type Mock,
} from 'vitest';
import { act, useEffect } from 'react';
import { render } from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { useCommandCompletion } from './useCommandCompletion.js';
import type { CommandContext } from '../commands/types.js';
@@ -132,7 +132,7 @@ describe('useCommandCompletion', () => {
hookResult = { ...completion, textBuffer };
return null;
}
render(<TestComponent />);
renderWithProviders(<TestComponent />);
return {
result: {
get current() {
@@ -516,7 +516,7 @@ describe('useCommandCompletion', () => {
hookResult = { ...completion, textBuffer };
return null;
}
render(<TestComponent />);
renderWithProviders(<TestComponent />);
// Should not trigger prompt completion for comments
expect(hookResult!.suggestions.length).toBe(0);
@@ -549,7 +549,7 @@ describe('useCommandCompletion', () => {
hookResult = { ...completion, textBuffer };
return null;
}
render(<TestComponent />);
renderWithProviders(<TestComponent />);
// Should not trigger prompt completion for comments
expect(hookResult!.suggestions.length).toBe(0);
@@ -582,7 +582,7 @@ describe('useCommandCompletion', () => {
hookResult = { ...completion, textBuffer };
return null;
}
render(<TestComponent />);
renderWithProviders(<TestComponent />);
// This test verifies that comments are filtered out while regular text is not
expect(hookResult!.textBuffer.text).toBe(
@@ -8,28 +8,56 @@ import { act, useCallback } from 'react';
import { vi } from 'vitest';
import { render } from '../../test-utils/render.js';
import { useConsoleMessages } from './useConsoleMessages.js';
import { CoreEvent, type ConsoleLogPayload } from '@google/gemini-cli-core';
// Mock coreEvents
let consoleLogHandler: ((payload: ConsoleLogPayload) => void) | undefined;
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = (await importOriginal()) as any;
return {
...actual,
coreEvents: {
on: vi.fn((event, handler) => {
if (event === CoreEvent.ConsoleLog) {
consoleLogHandler = handler;
}
}),
off: vi.fn((event) => {
if (event === CoreEvent.ConsoleLog) {
consoleLogHandler = undefined;
}
}),
emitConsoleLog: vi.fn(),
},
};
});
describe('useConsoleMessages', () => {
beforeEach(() => {
vi.useFakeTimers();
consoleLogHandler = undefined;
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.restoreAllMocks();
});
const useTestableConsoleMessages = () => {
const { handleNewMessage, ...rest } = useConsoleMessages();
const log = useCallback(
(content: string) => handleNewMessage({ type: 'log', content, count: 1 }),
[handleNewMessage],
);
const error = useCallback(
(content: string) =>
handleNewMessage({ type: 'error', content, count: 1 }),
[handleNewMessage],
);
const { ...rest } = useConsoleMessages();
const log = useCallback((content: string) => {
if (consoleLogHandler) {
consoleLogHandler({ type: 'log', content });
}
}, []);
const error = useCallback((content: string) => {
if (consoleLogHandler) {
consoleLogHandler({ type: 'error', content });
}
}, []);
return {
...rest,
log,
@@ -145,7 +173,7 @@ describe('useConsoleMessages', () => {
});
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
// clearTimeoutSpy.mockRestore() is handled by afterEach restoreAllMocks
});
it('should clean up the timeout on unmount', () => {
@@ -159,6 +187,5 @@ describe('useConsoleMessages', () => {
unmount();
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
});
@@ -12,10 +12,14 @@ import {
useTransition,
} from 'react';
import type { ConsoleMessageItem } from '../types.js';
import {
coreEvents,
CoreEvent,
type ConsoleLogPayload,
} from '@google/gemini-cli-core';
export interface UseConsoleMessagesReturn {
consoleMessages: ConsoleMessageItem[];
handleNewMessage: (message: ConsoleMessageItem) => void;
clearConsoleMessages: () => void;
}
@@ -85,6 +89,37 @@ export function useConsoleMessages(): UseConsoleMessagesReturn {
[processQueue],
);
useEffect(() => {
const handleConsoleLog = (payload: ConsoleLogPayload) => {
handleNewMessage({
type: payload.type,
content: payload.content,
count: 1,
});
};
const handleOutput = (payload: {
isStderr: boolean;
chunk: Uint8Array | string;
}) => {
const content =
typeof payload.chunk === 'string'
? payload.chunk
: new TextDecoder().decode(payload.chunk);
// It would be nice if we could show stderr as 'warn' but unfortunately
// we log non warning info to stderr before the app starts so that would
// be misleading.
handleNewMessage({ type: 'log', content, count: 1 });
};
coreEvents.on(CoreEvent.ConsoleLog, handleConsoleLog);
coreEvents.on(CoreEvent.Output, handleOutput);
return () => {
coreEvents.off(CoreEvent.ConsoleLog, handleConsoleLog);
coreEvents.off(CoreEvent.Output, handleOutput);
};
}, [handleNewMessage]);
const clearConsoleMessages = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
@@ -106,5 +141,5 @@ export function useConsoleMessages(): UseConsoleMessagesReturn {
[],
);
return { consoleMessages, handleNewMessage, clearConsoleMessages };
return { consoleMessages, clearConsoleMessages };
}
@@ -347,7 +347,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
() => {},
80,
24,
);
@@ -419,7 +418,6 @@ describe('useGeminiStream', () => {
setShellInputFocused?: (focused: boolean) => void;
performMemoryRefresh?: () => Promise<void>;
onAuthError?: () => void;
onEditorClose?: () => void;
setModelSwitched?: Mock;
modelSwitched?: boolean;
} = {},
@@ -430,7 +428,6 @@ describe('useGeminiStream', () => {
setShellInputFocused = () => {},
performMemoryRefresh = () => Promise.resolve(),
onAuthError = () => {},
onEditorClose = () => {},
setModelSwitched = vi.fn(),
modelSwitched = false,
} = options;
@@ -450,7 +447,6 @@ describe('useGeminiStream', () => {
performMemoryRefresh,
modelSwitched,
setModelSwitched,
onEditorClose,
onCancelSubmit,
setShellInputFocused,
80,
@@ -594,7 +590,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
() => {},
80,
24,
),
@@ -677,7 +672,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
() => {},
80,
24,
),
@@ -789,7 +783,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
() => {},
80,
24,
),
@@ -903,7 +896,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
() => {},
80,
24,
),
@@ -1035,7 +1027,6 @@ describe('useGeminiStream', () => {
() => Promise.resolve(),
false,
() => {},
() => {},
cancelSubmitSpy,
() => {},
80,
@@ -1076,7 +1067,6 @@ describe('useGeminiStream', () => {
() => Promise.resolve(),
false,
() => {},
() => {},
vi.fn(),
setShellInputFocusedSpy, // Pass the spy here
80,
@@ -1413,7 +1403,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
() => {},
80,
24,
),
@@ -1487,7 +1476,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
() => {},
80,
24,
),
@@ -1544,7 +1532,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
() => {},
80,
24,
),
@@ -1846,7 +1833,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
() => {},
80,
24,
),
@@ -1953,7 +1939,6 @@ describe('useGeminiStream', () => {
() => Promise.resolve(),
false,
() => {},
() => {},
onCancelSubmitSpy,
() => {},
80,
@@ -2098,7 +2083,6 @@ describe('useGeminiStream', () => {
vi.fn(), // performMemoryRefresh
false, // modelSwitched
vi.fn(), // setModelSwitched
vi.fn(), // onEditorClose
vi.fn(), // onCancelSubmit
vi.fn(), // setShellInputFocused
80, // terminalWidth
@@ -2171,7 +2155,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
() => {},
80,
24,
),
@@ -2252,7 +2235,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
() => {},
80,
24,
),
@@ -2322,7 +2304,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
() => {},
80,
24,
),
@@ -2380,7 +2361,6 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
() => {},
80,
24,
),
@@ -105,7 +105,6 @@ export const useGeminiStream = (
performMemoryRefresh: () => Promise<void>,
modelSwitchedFromQuotaError: boolean,
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
onEditorClose: () => void,
onCancelSubmit: (shouldRestorePrompt?: boolean) => void,
setShellInputFocused: (value: boolean) => void,
terminalWidth: number,
@@ -178,7 +177,6 @@ export const useGeminiStream = (
},
config,
getPreferredEditor,
onEditorClose,
);
const pendingToolCallGroupDisplay = useMemo(
@@ -8,6 +8,7 @@ import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { useInputHistoryStore } from './useInputHistoryStore.js';
import { debugLogger } from '@google/gemini-cli-core';
describe('useInputHistoryStore', () => {
beforeEach(() => {
@@ -108,7 +109,9 @@ describe('useInputHistoryStore', () => {
.mockRejectedValue(new Error('Logger error')),
};
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(debugLogger, 'warn')
.mockImplementation(() => {});
const { result } = renderHook(() => useInputHistoryStore());
@@ -29,7 +29,6 @@ describe('useReactToolScheduler', () => {
it('only creates one instance of CoreToolScheduler even if props change', () => {
const onComplete = vi.fn();
const getPreferredEditor = vi.fn();
const onEditorClose = vi.fn();
const config = {} as Config;
const { rerender } = renderHook(
@@ -38,14 +37,12 @@ describe('useReactToolScheduler', () => {
props.onComplete,
props.config,
props.getPreferredEditor,
props.onEditorClose,
),
{
initialProps: {
onComplete,
config,
getPreferredEditor,
onEditorClose,
},
},
);
@@ -58,7 +55,6 @@ describe('useReactToolScheduler', () => {
onComplete: newOnComplete,
config,
getPreferredEditor,
onEditorClose,
});
expect(mockCoreToolScheduler).toHaveBeenCalledTimes(1);
@@ -68,17 +64,13 @@ describe('useReactToolScheduler', () => {
onComplete: newOnComplete,
config,
getPreferredEditor: newGetPreferredEditor,
onEditorClose,
});
expect(mockCoreToolScheduler).toHaveBeenCalledTimes(1);
// Rerender with a new onEditorClose function
const newOnEditorClose = vi.fn();
rerender({
onComplete: newOnComplete,
config,
getPreferredEditor: newGetPreferredEditor,
onEditorClose: newOnEditorClose,
});
expect(mockCoreToolScheduler).toHaveBeenCalledTimes(1);
});
@@ -68,7 +68,6 @@ export function useReactToolScheduler(
onComplete: (tools: CompletedToolCall[]) => Promise<void>,
config: Config,
getPreferredEditor: () => EditorType | undefined,
onEditorClose: () => void,
): [
TrackedToolCall[],
ScheduleFn,
@@ -83,7 +82,6 @@ export function useReactToolScheduler(
// Store callbacks in refs to keep them up-to-date without causing re-renders.
const onCompleteRef = useRef(onComplete);
const getPreferredEditorRef = useRef(getPreferredEditor);
const onEditorCloseRef = useRef(onEditorClose);
useEffect(() => {
onCompleteRef.current = onComplete;
@@ -93,10 +91,6 @@ export function useReactToolScheduler(
getPreferredEditorRef.current = getPreferredEditor;
}, [getPreferredEditor]);
useEffect(() => {
onEditorCloseRef.current = onEditorClose;
}, [onEditorClose]);
const outputUpdateHandler: OutputUpdateHandler = useCallback(
(toolCallId, outputChunk) => {
setToolCallsForDisplay((prevCalls) =>
@@ -158,7 +152,6 @@ export function useReactToolScheduler(
() => getPreferredEditorRef.current(),
[],
);
const stableOnEditorClose = useCallback(() => onEditorCloseRef.current(), []);
const scheduler = useMemo(
() =>
@@ -168,7 +161,6 @@ export function useReactToolScheduler(
onToolCallsUpdate: toolCallsUpdateHandler,
getPreferredEditor: stableGetPreferredEditor,
config,
onEditorClose: stableOnEditorClose,
}),
[
config,
@@ -176,7 +168,6 @@ export function useReactToolScheduler(
allToolCallsCompleteHandler,
toolCallsUpdateHandler,
stableGetPreferredEditor,
stableOnEditorClose,
],
);
@@ -4,13 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { renderHookWithProviders } from '../../test-utils/render.js';
import { useReverseSearchCompletion } from './useReverseSearchCompletion.js';
import { useTextBuffer } from '../components/shared/text-buffer.js';
describe('useReverseSearchCompletion', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
function useTextBufferForTest(text: string) {
return useTextBuffer({
initialText: text,
@@ -26,7 +34,7 @@ describe('useReverseSearchCompletion', () => {
it('should initialize with default state', () => {
const mockShellHistory = ['echo hello'];
const { result } = renderHook(() =>
const { result } = renderHookWithProviders(() =>
useReverseSearchCompletion(
useTextBufferForTest(''),
mockShellHistory,
@@ -43,7 +51,7 @@ describe('useReverseSearchCompletion', () => {
it('should reset state when reverseSearchActive becomes false', () => {
const mockShellHistory = ['echo hello'];
const { result, rerender } = renderHook(
const { result, rerender } = renderHookWithProviders(
({ text, active }) => {
const textBuffer = useTextBufferForTest(text);
return useReverseSearchCompletion(
@@ -68,7 +76,7 @@ describe('useReverseSearchCompletion', () => {
it('should handle navigateUp with no suggestions', () => {
const mockShellHistory = ['echo hello'];
const { result } = renderHook(() =>
const { result } = renderHookWithProviders(() =>
useReverseSearchCompletion(
useTextBufferForTest('grep'),
mockShellHistory,
@@ -85,7 +93,7 @@ describe('useReverseSearchCompletion', () => {
it('should handle navigateDown with no suggestions', () => {
const mockShellHistory = ['echo hello'];
const { result } = renderHook(() =>
const { result } = renderHookWithProviders(() =>
useReverseSearchCompletion(
useTextBufferForTest('grep'),
mockShellHistory,
@@ -110,7 +118,7 @@ describe('useReverseSearchCompletion', () => {
'echo Hi',
];
const { result } = renderHook(() =>
const { result } = renderHookWithProviders(() =>
useReverseSearchCompletion(
useTextBufferForTest('echo'),
mockShellHistory,
@@ -137,7 +145,7 @@ describe('useReverseSearchCompletion', () => {
'echo "Hello, World!"',
'echo Hi',
];
const { result } = renderHook(() =>
const { result } = renderHookWithProviders(() =>
useReverseSearchCompletion(
useTextBufferForTest('ls'),
mockShellHistory,
@@ -165,7 +173,7 @@ describe('useReverseSearchCompletion', () => {
'echo "Hi all"',
];
const { result } = renderHook(() =>
const { result } = renderHookWithProviders(() =>
useReverseSearchCompletion(
useTextBufferForTest('l'),
mockShellHistory,
@@ -208,7 +216,7 @@ describe('useReverseSearchCompletion', () => {
(_, i) => `echo ${i}`,
);
const { result } = renderHook(() =>
const { result } = renderHookWithProviders(() =>
useReverseSearchCompletion(
useTextBufferForTest('echo'),
largeMockCommands,
@@ -234,7 +242,7 @@ describe('useReverseSearchCompletion', () => {
describe('Filtering', () => {
it('filters history by buffer.text and sets showSuggestions', () => {
const history = ['foo', 'barfoo', 'baz'];
const { result } = renderHook(() =>
const { result } = renderHookWithProviders(() =>
useReverseSearchCompletion(useTextBufferForTest('foo'), history, true),
);
@@ -248,7 +256,7 @@ describe('useReverseSearchCompletion', () => {
it('hides suggestions when there are no matches', () => {
const history = ['alpha', 'beta'];
const { result } = renderHook(() =>
const { result } = renderHookWithProviders(() =>
useReverseSearchCompletion(useTextBufferForTest('γ'), history, true),
);
@@ -135,7 +135,6 @@ describe('useReactToolScheduler in YOLO Mode', () => {
onComplete,
mockConfig as unknown as Config,
() => undefined,
() => {},
),
);
@@ -264,7 +263,6 @@ describe('useReactToolScheduler', () => {
onComplete,
mockConfig as unknown as Config,
() => undefined,
() => {},
),
);