mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-10 11:12:35 -07:00
Merging main
This commit is contained in:
@@ -220,6 +220,7 @@ describe('InputPrompt', () => {
|
||||
col = newText.length;
|
||||
}
|
||||
mockBuffer.cursor = [0, col];
|
||||
mockBuffer.allVisualLines = [newText];
|
||||
mockBuffer.viewportVisualLines = [newText];
|
||||
mockBuffer.allVisualLines = [newText];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
@@ -2273,6 +2274,7 @@ describe('InputPrompt', () => {
|
||||
async ({ text, visualCursor }) => {
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.allVisualLines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualCursor = visualCursor as [number, number];
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
@@ -2322,6 +2324,7 @@ describe('InputPrompt', () => {
|
||||
async ({ text, visualCursor, visualToLogicalMap }) => {
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = text.split('\n');
|
||||
mockBuffer.allVisualLines = text.split('\n');
|
||||
mockBuffer.viewportVisualLines = text.split('\n');
|
||||
mockBuffer.visualCursor = visualCursor as [number, number];
|
||||
mockBuffer.visualToLogicalMap = visualToLogicalMap as Array<
|
||||
@@ -2342,6 +2345,7 @@ describe('InputPrompt', () => {
|
||||
const text = 'first line\n\nthird line';
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = text.split('\n');
|
||||
mockBuffer.allVisualLines = text.split('\n');
|
||||
mockBuffer.viewportVisualLines = text.split('\n');
|
||||
mockBuffer.visualCursor = [1, 0]; // cursor on the blank line
|
||||
mockBuffer.visualToLogicalMap = [
|
||||
@@ -2361,11 +2365,98 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrolling large inputs', () => {
|
||||
it('should correctly render scrolling down and up for large inputs', async () => {
|
||||
const lines = Array.from({ length: 50 }).map((_, i) => `testline ${i}`);
|
||||
|
||||
// Since we need to test how the React component tree responds to TextBuffer state changes,
|
||||
// we must provide a fake TextBuffer implementation that triggers re-renders like the real one.
|
||||
|
||||
const TestWrapper = () => {
|
||||
const [bufferState, setBufferState] = useState({
|
||||
text: lines.join('\n'),
|
||||
lines,
|
||||
allVisualLines: lines,
|
||||
viewportVisualLines: lines.slice(0, 10),
|
||||
visualToLogicalMap: lines.map((_, i) => [i, 0]),
|
||||
visualCursor: [0, 0] as [number, number],
|
||||
visualScrollRow: 0,
|
||||
viewportHeight: 10,
|
||||
});
|
||||
|
||||
const fakeBuffer = {
|
||||
...mockBuffer,
|
||||
...bufferState,
|
||||
handleInput: vi.fn().mockImplementation((key) => {
|
||||
let newRow = bufferState.visualCursor[0];
|
||||
let newScroll = bufferState.visualScrollRow;
|
||||
if (key.name === 'down') {
|
||||
newRow = Math.min(49, newRow + 1);
|
||||
if (newRow >= newScroll + 10) newScroll++;
|
||||
} else if (key.name === 'up') {
|
||||
newRow = Math.max(0, newRow - 1);
|
||||
if (newRow < newScroll) newScroll--;
|
||||
}
|
||||
setBufferState({
|
||||
...bufferState,
|
||||
visualCursor: [newRow, 0],
|
||||
visualScrollRow: newScroll,
|
||||
viewportVisualLines: lines.slice(newScroll, newScroll + 10),
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
} as unknown as TextBuffer;
|
||||
|
||||
return <InputPrompt {...props} buffer={fakeBuffer} />;
|
||||
};
|
||||
|
||||
const { stdout, unmount, stdin } = await renderWithProviders(
|
||||
<TestWrapper />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
// Verify initial render
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('testline 0');
|
||||
expect(stdout.lastFrame()).not.toContain('testline 49');
|
||||
});
|
||||
|
||||
// Move cursor to bottom
|
||||
for (let i = 0; i < 49; i++) {
|
||||
act(() => {
|
||||
stdin.write('\x1b[B'); // Arrow Down
|
||||
});
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('testline 49');
|
||||
expect(stdout.lastFrame()).not.toContain('testline 0');
|
||||
});
|
||||
|
||||
// Move cursor back to top
|
||||
for (let i = 0; i < 49; i++) {
|
||||
act(() => {
|
||||
stdin.write('\x1b[A'); // Arrow Up
|
||||
});
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('testline 0');
|
||||
expect(stdout.lastFrame()).not.toContain('testline 49');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiline rendering', () => {
|
||||
it('should correctly render multiline input including blank lines', async () => {
|
||||
const text = 'hello\n\nworld';
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = text.split('\n');
|
||||
mockBuffer.allVisualLines = text.split('\n');
|
||||
mockBuffer.viewportVisualLines = text.split('\n');
|
||||
mockBuffer.allVisualLines = text.split('\n');
|
||||
mockBuffer.visualCursor = [2, 5]; // cursor at the end of "world"
|
||||
@@ -3592,7 +3683,9 @@ describe('InputPrompt', () => {
|
||||
async ({ relX, relY, mouseCol, mouseRow }) => {
|
||||
props.buffer.text = 'hello world\nsecond line';
|
||||
props.buffer.lines = ['hello world', 'second line'];
|
||||
props.buffer.allVisualLines = ['hello world', 'second line'];
|
||||
props.buffer.viewportVisualLines = ['hello world', 'second line'];
|
||||
props.buffer.viewportHeight = 10;
|
||||
props.buffer.visualToLogicalMap = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
@@ -3630,6 +3723,7 @@ describe('InputPrompt', () => {
|
||||
it('should unfocus embedded shell on click', async () => {
|
||||
props.buffer.text = 'hello';
|
||||
props.buffer.lines = ['hello'];
|
||||
props.buffer.allVisualLines = ['hello'];
|
||||
props.buffer.viewportVisualLines = ['hello'];
|
||||
props.buffer.visualToLogicalMap = [[0, 0]];
|
||||
props.isEmbeddedShellFocused = true;
|
||||
@@ -3671,6 +3765,7 @@ describe('InputPrompt', () => {
|
||||
lines: currentLines,
|
||||
viewportVisualLines: currentLines,
|
||||
allVisualLines: currentLines,
|
||||
viewportHeight: 10,
|
||||
pastedContent: { [id]: largeText },
|
||||
transformationsByLine: isExpanded
|
||||
? currentLines.map(() => [])
|
||||
@@ -3759,6 +3854,7 @@ describe('InputPrompt', () => {
|
||||
lines: currentLines,
|
||||
viewportVisualLines: currentLines,
|
||||
allVisualLines: currentLines,
|
||||
viewportHeight: 10,
|
||||
pastedContent: { [id]: largeText },
|
||||
transformationsByLine: isExpanded
|
||||
? currentLines.map(() => [])
|
||||
@@ -3830,7 +3926,9 @@ describe('InputPrompt', () => {
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
props.buffer.text = 'hello world';
|
||||
props.buffer.lines = ['hello world'];
|
||||
props.buffer.allVisualLines = ['hello world'];
|
||||
props.buffer.viewportVisualLines = ['hello world'];
|
||||
props.buffer.viewportHeight = 10;
|
||||
props.buffer.visualToLogicalMap = [[0, 0]];
|
||||
props.buffer.visualCursor = [0, 11];
|
||||
props.buffer.visualScrollRow = 0;
|
||||
@@ -4137,6 +4235,7 @@ describe('InputPrompt', () => {
|
||||
const text = 'hello';
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.allVisualLines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, 3]; // Cursor after 'hel'
|
||||
@@ -4167,6 +4266,7 @@ describe('InputPrompt', () => {
|
||||
const text = '👍hello';
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.allVisualLines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, 2]; // Cursor after '👍h' (Note: '👍' is one code point but width 2)
|
||||
@@ -4196,6 +4296,7 @@ describe('InputPrompt', () => {
|
||||
const text = '😀😀😀';
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.allVisualLines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, 2]; // Cursor after 2 emojis (each 1 code point, width 2)
|
||||
@@ -4225,7 +4326,9 @@ describe('InputPrompt', () => {
|
||||
const lines = ['😀😀', 'hello 😀', 'world'];
|
||||
mockBuffer.text = lines.join('\n');
|
||||
mockBuffer.lines = lines;
|
||||
mockBuffer.allVisualLines = lines;
|
||||
mockBuffer.viewportVisualLines = lines;
|
||||
mockBuffer.viewportHeight = 10;
|
||||
mockBuffer.visualToLogicalMap = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
@@ -4262,7 +4365,9 @@ describe('InputPrompt', () => {
|
||||
const lines = ['first line', 'second line', 'third line'];
|
||||
mockBuffer.text = lines.join('\n');
|
||||
mockBuffer.lines = lines;
|
||||
mockBuffer.allVisualLines = lines;
|
||||
mockBuffer.viewportVisualLines = lines;
|
||||
mockBuffer.viewportHeight = 10;
|
||||
mockBuffer.visualToLogicalMap = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
@@ -4303,6 +4408,7 @@ describe('InputPrompt', () => {
|
||||
it('should report cursor position 0 when input is empty and placeholder is shown', async () => {
|
||||
mockBuffer.text = '';
|
||||
mockBuffer.lines = [''];
|
||||
mockBuffer.allVisualLines = [''];
|
||||
mockBuffer.viewportVisualLines = [''];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, 0];
|
||||
@@ -4335,6 +4441,7 @@ describe('InputPrompt', () => {
|
||||
const applyVisualState = (visualLine: string, cursorCol: number): void => {
|
||||
mockBuffer.text = logicalLine;
|
||||
mockBuffer.lines = [logicalLine];
|
||||
mockBuffer.allVisualLines = [visualLine];
|
||||
mockBuffer.viewportVisualLines = [visualLine];
|
||||
mockBuffer.allVisualLines = [visualLine];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
|
||||
@@ -12,6 +12,10 @@ import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
|
||||
import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
} from './shared/ScrollableList.js';
|
||||
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
|
||||
import {
|
||||
type TextBuffer,
|
||||
@@ -95,6 +99,10 @@ export function isTerminalPasteTrusted(
|
||||
return kittyProtocolSupported;
|
||||
}
|
||||
|
||||
export type ScrollableItem =
|
||||
| { type: 'visualLine'; lineText: string; absoluteVisualIdx: number }
|
||||
| { type: 'ghostLine'; ghostLine: string; index: number };
|
||||
|
||||
export interface InputPromptProps {
|
||||
buffer: TextBuffer;
|
||||
onSubmit: (value: string) => void;
|
||||
@@ -268,6 +276,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const innerBoxRef = useRef<DOMElement>(null);
|
||||
const hasUserNavigatedSuggestions = useRef(false);
|
||||
const listRef = useRef<ScrollableListRef<ScrollableItem>>(null);
|
||||
|
||||
const [reverseSearchActive, setReverseSearchActive] = useState(false);
|
||||
const [commandSearchActive, setCommandSearchActive] = useState(false);
|
||||
@@ -556,7 +565,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (isEmbeddedShellFocused) {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
const visualRow = buffer.visualScrollRow + relY;
|
||||
const currentScrollTop = Math.round(
|
||||
listRef.current?.getScrollState().scrollTop ?? buffer.visualScrollRow,
|
||||
);
|
||||
const visualRow = currentScrollTop + relY;
|
||||
buffer.moveToVisualPosition(visualRow, relX);
|
||||
},
|
||||
{ isActive: focus },
|
||||
@@ -570,7 +582,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
(_event, relX, relY) => {
|
||||
if (!isAlternateBuffer) return;
|
||||
|
||||
const visualLine = buffer.viewportVisualLines[relY];
|
||||
const currentScrollTop = Math.round(
|
||||
listRef.current?.getScrollState().scrollTop ?? buffer.visualScrollRow,
|
||||
);
|
||||
const visualLine = buffer.allVisualLines[currentScrollTop + relY];
|
||||
if (!visualLine) return;
|
||||
|
||||
// Even if we click past the end of the line, we might want to collapse an expanded paste
|
||||
@@ -578,10 +593,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const logicalPos = isPastEndOfLine
|
||||
? null
|
||||
: buffer.getLogicalPositionFromVisual(
|
||||
buffer.visualScrollRow + relY,
|
||||
relX,
|
||||
);
|
||||
: buffer.getLogicalPositionFromVisual(currentScrollTop + relY, relX);
|
||||
|
||||
// Check for paste placeholder (collapsed state)
|
||||
if (logicalPos) {
|
||||
@@ -603,7 +615,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
// If we didn't click a placeholder to expand, check if we are inside or after
|
||||
// an expanded paste region and collapse it.
|
||||
const row = buffer.visualScrollRow + relY;
|
||||
const visualRow = currentScrollTop + relY;
|
||||
const mapEntry = buffer.visualToLogicalMap[visualRow];
|
||||
const row = mapEntry ? mapEntry[0] : visualRow;
|
||||
const expandedId = buffer.getExpandedPasteAtLine(row);
|
||||
if (expandedId) {
|
||||
buffer.togglePasteExpansion(
|
||||
@@ -1350,10 +1364,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
priority: true,
|
||||
});
|
||||
|
||||
const linesToRender = buffer.viewportVisualLines;
|
||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
||||
buffer.visualCursor;
|
||||
const scrollVisualRow = buffer.visualScrollRow;
|
||||
|
||||
const getGhostTextLines = useCallback(() => {
|
||||
if (
|
||||
@@ -1468,6 +1480,155 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const { inlineGhost, additionalLines } = getGhostTextLines();
|
||||
|
||||
const scrollableData = useMemo(() => {
|
||||
const items: ScrollableItem[] = buffer.allVisualLines.map(
|
||||
(lineText, index) => ({
|
||||
type: 'visualLine',
|
||||
lineText,
|
||||
absoluteVisualIdx: index,
|
||||
}),
|
||||
);
|
||||
|
||||
additionalLines.forEach((ghostLine, index) => {
|
||||
items.push({
|
||||
type: 'ghostLine',
|
||||
ghostLine,
|
||||
index,
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [buffer.allVisualLines, additionalLines]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: ScrollableItem; index: number }) => {
|
||||
if (item.type === 'ghostLine') {
|
||||
const padding = Math.max(0, inputWidth - stringWidth(item.ghostLine));
|
||||
return (
|
||||
<Box height={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.ghostLine}
|
||||
{' '.repeat(padding)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const { lineText, absoluteVisualIdx } = item;
|
||||
// console.log('renderItem called with:', lineText);
|
||||
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
|
||||
if (!mapEntry) return <Text> </Text>;
|
||||
|
||||
const isOnCursorLine =
|
||||
focus && absoluteVisualIdx === cursorVisualRowAbsolute;
|
||||
const renderedLine: React.ReactNode[] = [];
|
||||
const [logicalLineIdx] = mapEntry;
|
||||
const logicalLine = buffer.lines[logicalLineIdx] || '';
|
||||
const transformations =
|
||||
buffer.transformationsByLine[logicalLineIdx] ?? [];
|
||||
const tokens = parseInputForHighlighting(
|
||||
logicalLine,
|
||||
logicalLineIdx,
|
||||
transformations,
|
||||
...(focus && buffer.cursor[0] === logicalLineIdx
|
||||
? [buffer.cursor[1]]
|
||||
: []),
|
||||
);
|
||||
const visualStartCol =
|
||||
buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0;
|
||||
const visualEndCol = visualStartCol + cpLen(lineText);
|
||||
const segments = parseSegmentsFromTokens(
|
||||
tokens,
|
||||
visualStartCol,
|
||||
visualEndCol,
|
||||
);
|
||||
let charCount = 0;
|
||||
segments.forEach((seg, segIdx) => {
|
||||
const segLen = cpLen(seg.text);
|
||||
let display = seg.text;
|
||||
if (isOnCursorLine) {
|
||||
const relCol = cursorVisualColAbsolute;
|
||||
const segStart = charCount;
|
||||
const segEnd = segStart + segLen;
|
||||
if (relCol >= segStart && relCol < segEnd) {
|
||||
const charToHighlight = cpSlice(
|
||||
display,
|
||||
relCol - segStart,
|
||||
relCol - segStart + 1,
|
||||
);
|
||||
const highlighted = showCursor
|
||||
? chalk.inverse(charToHighlight)
|
||||
: charToHighlight;
|
||||
display =
|
||||
cpSlice(display, 0, relCol - segStart) +
|
||||
highlighted +
|
||||
cpSlice(display, relCol - segStart + 1);
|
||||
}
|
||||
charCount = segEnd;
|
||||
} else {
|
||||
charCount += segLen;
|
||||
}
|
||||
const color =
|
||||
seg.type === 'command' || seg.type === 'file' || seg.type === 'paste'
|
||||
? theme.text.accent
|
||||
: theme.text.primary;
|
||||
renderedLine.push(
|
||||
<Text key={`token-${segIdx}`} color={color}>
|
||||
{display}
|
||||
</Text>,
|
||||
);
|
||||
});
|
||||
|
||||
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
|
||||
if (
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText) &&
|
||||
!currentLineGhost
|
||||
) {
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursor ? chalk.inverse(' ') : ' '}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
const showCursorBeforeGhost =
|
||||
focus &&
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText) &&
|
||||
currentLineGhost;
|
||||
return (
|
||||
<Box height={1}>
|
||||
<Text
|
||||
terminalCursorFocus={showCursor && isOnCursorLine}
|
||||
terminalCursorPosition={cpIndexToOffset(
|
||||
lineText,
|
||||
cursorVisualColAbsolute,
|
||||
)}
|
||||
>
|
||||
{renderedLine}
|
||||
{showCursorBeforeGhost && (showCursor ? chalk.inverse(' ') : ' ')}
|
||||
{currentLineGhost && (
|
||||
<Text color={theme.text.secondary}>{currentLineGhost}</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
[
|
||||
buffer.visualToLogicalMap,
|
||||
buffer.lines,
|
||||
buffer.transformationsByLine,
|
||||
buffer.cursor,
|
||||
buffer.visualToTransformedMap,
|
||||
focus,
|
||||
cursorVisualRowAbsolute,
|
||||
cursorVisualColAbsolute,
|
||||
showCursor,
|
||||
inlineGhost,
|
||||
inputWidth,
|
||||
],
|
||||
);
|
||||
|
||||
const useBackgroundColor = config.getUseBackgroundColor();
|
||||
const isLowColor = isLowColorDepth();
|
||||
const terminalBg = theme.background.primary || 'black';
|
||||
@@ -1485,6 +1646,46 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return false;
|
||||
}, [useBackgroundColor, isLowColor, terminalBg]);
|
||||
|
||||
const prevCursorRef = useRef(buffer.visualCursor);
|
||||
const prevTextRef = useRef(buffer.text);
|
||||
|
||||
// Effect to ensure cursor remains visible after interactions
|
||||
useEffect(() => {
|
||||
const cursorChanged = prevCursorRef.current !== buffer.visualCursor;
|
||||
const textChanged = prevTextRef.current !== buffer.text;
|
||||
|
||||
prevCursorRef.current = buffer.visualCursor;
|
||||
prevTextRef.current = buffer.text;
|
||||
|
||||
if (!cursorChanged && !textChanged) return;
|
||||
|
||||
if (!listRef.current || !focus) return;
|
||||
const { scrollTop, innerHeight } = listRef.current.getScrollState();
|
||||
if (innerHeight === 0) return;
|
||||
|
||||
const cursorVisualRow = buffer.visualCursor[0];
|
||||
const actualScrollTop = Math.round(scrollTop);
|
||||
|
||||
// If cursor is out of the currently visible viewport...
|
||||
if (
|
||||
cursorVisualRow < actualScrollTop ||
|
||||
cursorVisualRow >= actualScrollTop + innerHeight
|
||||
) {
|
||||
// Calculate minimal scroll to make it visible
|
||||
let newScrollTop = actualScrollTop;
|
||||
if (cursorVisualRow < actualScrollTop) {
|
||||
newScrollTop = cursorVisualRow;
|
||||
} else if (cursorVisualRow >= actualScrollTop + innerHeight) {
|
||||
newScrollTop = cursorVisualRow - innerHeight + 1;
|
||||
}
|
||||
|
||||
listRef.current.scrollToIndex({ index: newScrollTop });
|
||||
}
|
||||
}, [buffer.visualCursor, buffer.text, focus]);
|
||||
|
||||
const listBackgroundColor =
|
||||
useLineFallback || !useBackgroundColor ? undefined : theme.background.input;
|
||||
|
||||
useEffect(() => {
|
||||
if (onSuggestionsVisibilityChange) {
|
||||
onSuggestionsVisibilityChange(shouldShowSuggestions);
|
||||
@@ -1615,153 +1816,30 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||
)
|
||||
) : (
|
||||
linesToRender
|
||||
.map((lineText: string, visualIdxInRenderedSet: number) => {
|
||||
const absoluteVisualIdx =
|
||||
scrollVisualRow + visualIdxInRenderedSet;
|
||||
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
|
||||
if (!mapEntry) return null;
|
||||
|
||||
const cursorVisualRow =
|
||||
cursorVisualRowAbsolute - scrollVisualRow;
|
||||
const isOnCursorLine =
|
||||
focus && visualIdxInRenderedSet === cursorVisualRow;
|
||||
|
||||
const renderedLine: React.ReactNode[] = [];
|
||||
|
||||
const [logicalLineIdx] = mapEntry;
|
||||
const logicalLine = buffer.lines[logicalLineIdx] || '';
|
||||
const transformations =
|
||||
buffer.transformationsByLine[logicalLineIdx] ?? [];
|
||||
const tokens = parseInputForHighlighting(
|
||||
logicalLine,
|
||||
logicalLineIdx,
|
||||
transformations,
|
||||
...(focus && buffer.cursor[0] === logicalLineIdx
|
||||
? [buffer.cursor[1]]
|
||||
: []),
|
||||
);
|
||||
const startColInTransformed =
|
||||
buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0;
|
||||
const visualStartCol = startColInTransformed;
|
||||
const visualEndCol = visualStartCol + cpLen(lineText);
|
||||
const segments = parseSegmentsFromTokens(
|
||||
tokens,
|
||||
visualStartCol,
|
||||
visualEndCol,
|
||||
);
|
||||
let charCount = 0;
|
||||
segments.forEach((seg, segIdx) => {
|
||||
const segLen = cpLen(seg.text);
|
||||
let display = seg.text;
|
||||
|
||||
if (isOnCursorLine) {
|
||||
const relativeVisualColForHighlight =
|
||||
cursorVisualColAbsolute;
|
||||
const segStart = charCount;
|
||||
const segEnd = segStart + segLen;
|
||||
if (
|
||||
relativeVisualColForHighlight >= segStart &&
|
||||
relativeVisualColForHighlight < segEnd
|
||||
) {
|
||||
const charToHighlight = cpSlice(
|
||||
display,
|
||||
relativeVisualColForHighlight - segStart,
|
||||
relativeVisualColForHighlight - segStart + 1,
|
||||
);
|
||||
const highlighted = showCursor
|
||||
? chalk.inverse(charToHighlight)
|
||||
: charToHighlight;
|
||||
display =
|
||||
cpSlice(
|
||||
display,
|
||||
0,
|
||||
relativeVisualColForHighlight - segStart,
|
||||
) +
|
||||
highlighted +
|
||||
cpSlice(
|
||||
display,
|
||||
relativeVisualColForHighlight - segStart + 1,
|
||||
);
|
||||
}
|
||||
charCount = segEnd;
|
||||
} else {
|
||||
// Advance the running counter even when not on cursor line
|
||||
charCount += segLen;
|
||||
}
|
||||
|
||||
const color =
|
||||
seg.type === 'command' ||
|
||||
seg.type === 'file' ||
|
||||
seg.type === 'paste'
|
||||
? theme.text.accent
|
||||
: theme.text.primary;
|
||||
|
||||
renderedLine.push(
|
||||
<Text key={`token-${segIdx}`} color={color}>
|
||||
{display}
|
||||
</Text>,
|
||||
);
|
||||
});
|
||||
|
||||
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
|
||||
if (
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText)
|
||||
) {
|
||||
if (!currentLineGhost) {
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursor ? chalk.inverse(' ') : ' '}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height={Math.min(buffer.viewportHeight, scrollableData.length)}
|
||||
width="100%"
|
||||
>
|
||||
<ScrollableList
|
||||
ref={listRef}
|
||||
hasFocus={focus}
|
||||
data={scrollableData}
|
||||
renderItem={renderItem}
|
||||
estimatedItemHeight={() => 1}
|
||||
keyExtractor={(item) =>
|
||||
item.type === 'visualLine'
|
||||
? `line-${item.absoluteVisualIdx}`
|
||||
: `ghost-${item.index}`
|
||||
}
|
||||
|
||||
const showCursorBeforeGhost =
|
||||
focus &&
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText) &&
|
||||
currentLineGhost;
|
||||
|
||||
return (
|
||||
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
|
||||
<Text
|
||||
terminalCursorFocus={showCursor && isOnCursorLine}
|
||||
terminalCursorPosition={cpIndexToOffset(
|
||||
lineText,
|
||||
cursorVisualColAbsolute,
|
||||
)}
|
||||
>
|
||||
{renderedLine}
|
||||
{showCursorBeforeGhost &&
|
||||
(showCursor ? chalk.inverse(' ') : ' ')}
|
||||
{currentLineGhost && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentLineGhost}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
.concat(
|
||||
additionalLines.map((ghostLine, index) => {
|
||||
const padding = Math.max(
|
||||
0,
|
||||
inputWidth - stringWidth(ghostLine),
|
||||
);
|
||||
return (
|
||||
<Text
|
||||
key={`ghost-line-${index}`}
|
||||
color={theme.text.secondary}
|
||||
>
|
||||
{ghostLine}
|
||||
{' '.repeat(padding)}
|
||||
</Text>
|
||||
);
|
||||
}),
|
||||
)
|
||||
width="100%"
|
||||
backgroundColor={listBackgroundColor}
|
||||
containerHeight={Math.min(
|
||||
buffer.viewportHeight,
|
||||
scrollableData.length,
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -93,7 +93,7 @@ exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios >
|
||||
exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> second message
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -120,30 +120,30 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
(r:) commit
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
git commit -m "feat: add search" in src/app
|
||||
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
(r:) commit
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
git commit -m "feat: add search" in src/app
|
||||
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Image ...reenshot2x.png]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> @/path/to/screenshots/screenshot2x.png
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ interface ScrollableListProps<T> extends VirtualizedListProps<T> {
|
||||
copyModeEnabled?: boolean;
|
||||
isStatic?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
targetScrollIndex?: number;
|
||||
containerHeight?: number;
|
||||
scrollbarThumbColor?: string;
|
||||
}
|
||||
|
||||
export type ScrollableListRef<T> = VirtualizedListRef<T>;
|
||||
|
||||
@@ -29,6 +29,8 @@ export type VirtualizedListProps<T> = {
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
initialScrollIndex?: number;
|
||||
initialScrollOffsetInIndex?: number;
|
||||
targetScrollIndex?: number;
|
||||
backgroundColor?: string;
|
||||
scrollbarThumbColor?: string;
|
||||
renderStatic?: boolean;
|
||||
isStatic?: boolean;
|
||||
@@ -39,6 +41,7 @@ export type VirtualizedListProps<T> = {
|
||||
stableScrollback?: boolean;
|
||||
copyModeEnabled?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
containerHeight?: number;
|
||||
};
|
||||
|
||||
export type VirtualizedListRef<T> = {
|
||||
@@ -159,6 +162,17 @@ function VirtualizedList<T>(
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof props.targetScrollIndex === 'number') {
|
||||
// NOTE: When targetScrollIndex is specified, we rely on the component
|
||||
// correctly tracking targetScrollIndex instead of initialScrollIndex.
|
||||
// We set isInitialScrollSet.current = true inside the second layout effect
|
||||
// to avoid it overwriting the targetScrollIndex.
|
||||
return {
|
||||
index: props.targetScrollIndex,
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { index: 0, offset: 0 };
|
||||
});
|
||||
|
||||
@@ -242,7 +256,7 @@ function VirtualizedList<T>(
|
||||
return { totalHeight, offsets };
|
||||
}, [heights, data, estimatedItemHeight, keyExtractor]);
|
||||
|
||||
const scrollableContainerHeight = containerHeight;
|
||||
const scrollableContainerHeight = props.containerHeight ?? containerHeight;
|
||||
|
||||
const getAnchorForScrollTop = useCallback(
|
||||
(
|
||||
@@ -259,6 +273,32 @@ function VirtualizedList<T>(
|
||||
[],
|
||||
);
|
||||
|
||||
const [prevTargetScrollIndex, setPrevTargetScrollIndex] = useState(
|
||||
props.targetScrollIndex,
|
||||
);
|
||||
const prevOffsetsLength = useRef(offsets.length);
|
||||
|
||||
// NOTE: If targetScrollIndex is provided, and we haven't rendered items yet (offsets.length <= 1),
|
||||
// we do NOT set scrollAnchor yet, because actualScrollTop wouldn't know the real offset!
|
||||
// We wait until offsets populate.
|
||||
if (
|
||||
(props.targetScrollIndex !== undefined &&
|
||||
props.targetScrollIndex !== prevTargetScrollIndex &&
|
||||
offsets.length > 1) ||
|
||||
(props.targetScrollIndex !== undefined &&
|
||||
prevOffsetsLength.current <= 1 &&
|
||||
offsets.length > 1)
|
||||
) {
|
||||
if (props.targetScrollIndex !== prevTargetScrollIndex) {
|
||||
setPrevTargetScrollIndex(props.targetScrollIndex);
|
||||
}
|
||||
prevOffsetsLength.current = offsets.length;
|
||||
setIsStickingToBottom(false);
|
||||
setScrollAnchor({ index: props.targetScrollIndex, offset: 0 });
|
||||
} else {
|
||||
prevOffsetsLength.current = offsets.length;
|
||||
}
|
||||
|
||||
const actualScrollTop = useMemo(() => {
|
||||
const offset = offsets[scrollAnchor.index];
|
||||
if (typeof offset !== 'number') {
|
||||
@@ -309,9 +349,14 @@ function VirtualizedList<T>(
|
||||
const containerChanged =
|
||||
prevContainerHeight.current !== scrollableContainerHeight;
|
||||
|
||||
// If targetScrollIndex is provided, we NEVER auto-snap to the bottom
|
||||
// because the parent is explicitly managing the scroll position.
|
||||
const shouldAutoScroll = props.targetScrollIndex === undefined;
|
||||
|
||||
if (
|
||||
(listGrew && (isStickingToBottom || wasAtBottom)) ||
|
||||
(isStickingToBottom && containerChanged)
|
||||
shouldAutoScroll &&
|
||||
((listGrew && (isStickingToBottom || wasAtBottom)) ||
|
||||
(isStickingToBottom && containerChanged))
|
||||
) {
|
||||
const newIndex = data.length > 0 ? data.length - 1 : 0;
|
||||
if (
|
||||
@@ -331,6 +376,7 @@ function VirtualizedList<T>(
|
||||
actualScrollTop > totalHeight - scrollableContainerHeight) &&
|
||||
data.length > 0
|
||||
) {
|
||||
// We still clamp the scroll top if it's completely out of bounds
|
||||
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const newAnchor = getAnchorForScrollTop(newScrollTop, offsets);
|
||||
if (
|
||||
@@ -359,6 +405,7 @@ function VirtualizedList<T>(
|
||||
getAnchorForScrollTop,
|
||||
offsets,
|
||||
isStickingToBottom,
|
||||
props.targetScrollIndex,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -366,11 +413,17 @@ function VirtualizedList<T>(
|
||||
isInitialScrollSet.current ||
|
||||
offsets.length <= 1 ||
|
||||
totalHeight <= 0 ||
|
||||
containerHeight <= 0
|
||||
scrollableContainerHeight <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.targetScrollIndex !== undefined) {
|
||||
// If we are strictly driving from targetScrollIndex, do not apply initialScrollIndex
|
||||
isInitialScrollSet.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof initialScrollIndex === 'number') {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
@@ -404,19 +457,21 @@ function VirtualizedList<T>(
|
||||
initialScrollOffsetInIndex,
|
||||
offsets,
|
||||
totalHeight,
|
||||
containerHeight,
|
||||
scrollableContainerHeight,
|
||||
getAnchorForScrollTop,
|
||||
data.length,
|
||||
heights,
|
||||
scrollableContainerHeight,
|
||||
props.targetScrollIndex,
|
||||
]);
|
||||
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1,
|
||||
);
|
||||
const viewHeightForEndIndex =
|
||||
scrollableContainerHeight > 0 ? scrollableContainerHeight : 50;
|
||||
const endIndexOffset = offsets.findIndex(
|
||||
(offset) => offset > actualScrollTop + scrollableContainerHeight,
|
||||
(offset) => offset > actualScrollTop + viewHeightForEndIndex,
|
||||
);
|
||||
const endIndex =
|
||||
endIndexOffset === -1
|
||||
@@ -618,11 +673,11 @@ function VirtualizedList<T>(
|
||||
},
|
||||
getScrollIndex: () => scrollAnchor.index,
|
||||
getScrollState: () => {
|
||||
const maxScroll = Math.max(0, totalHeight - containerHeight);
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
return {
|
||||
scrollTop: Math.min(getScrollTop(), maxScroll),
|
||||
scrollHeight: totalHeight,
|
||||
innerHeight: containerHeight,
|
||||
innerHeight: scrollableContainerHeight,
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -635,7 +690,6 @@ function VirtualizedList<T>(
|
||||
scrollableContainerHeight,
|
||||
getScrollTop,
|
||||
setPendingScrollTop,
|
||||
containerHeight,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -646,6 +700,7 @@ function VirtualizedList<T>(
|
||||
overflowX="hidden"
|
||||
scrollTop={copyModeEnabled ? 0 : scrollTop}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
backgroundColor={props.backgroundColor}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
|
||||
@@ -2907,6 +2907,25 @@ export function useTextBuffer({
|
||||
|
||||
const [scrollRowState, setScrollRowState] = useState<number>(0);
|
||||
|
||||
const { height } = viewport;
|
||||
const totalVisualLines = visualLines.length;
|
||||
const maxScrollStart = Math.max(0, totalVisualLines - height);
|
||||
let newVisualScrollRow = scrollRowState;
|
||||
|
||||
if (visualCursor[0] < scrollRowState) {
|
||||
newVisualScrollRow = visualCursor[0];
|
||||
} else if (visualCursor[0] >= scrollRowState + height) {
|
||||
newVisualScrollRow = visualCursor[0] - height + 1;
|
||||
}
|
||||
|
||||
newVisualScrollRow = clamp(newVisualScrollRow, 0, maxScrollStart);
|
||||
|
||||
if (newVisualScrollRow !== scrollRowState) {
|
||||
setScrollRowState(newVisualScrollRow);
|
||||
}
|
||||
|
||||
const actualScrollRowState = newVisualScrollRow;
|
||||
|
||||
useEffect(() => {
|
||||
if (onChange) {
|
||||
onChange(text);
|
||||
@@ -2920,28 +2939,6 @@ export function useTextBuffer({
|
||||
});
|
||||
}, [viewport.width, viewport.height]);
|
||||
|
||||
// Update visual scroll (vertical)
|
||||
useEffect(() => {
|
||||
const { height } = viewport;
|
||||
const totalVisualLines = visualLines.length;
|
||||
const maxScrollStart = Math.max(0, totalVisualLines - height);
|
||||
let newVisualScrollRow = scrollRowState;
|
||||
|
||||
if (visualCursor[0] < scrollRowState) {
|
||||
newVisualScrollRow = visualCursor[0];
|
||||
} else if (visualCursor[0] >= scrollRowState + height) {
|
||||
newVisualScrollRow = visualCursor[0] - height + 1;
|
||||
}
|
||||
|
||||
// When the number of visual lines shrinks (e.g., after widening the viewport),
|
||||
// ensure scroll never starts beyond the last valid start so we can render a full window.
|
||||
newVisualScrollRow = clamp(newVisualScrollRow, 0, maxScrollStart);
|
||||
|
||||
if (newVisualScrollRow !== scrollRowState) {
|
||||
setScrollRowState(newVisualScrollRow);
|
||||
}
|
||||
}, [visualCursor, scrollRowState, viewport, visualLines.length]);
|
||||
|
||||
const insert = useCallback(
|
||||
(ch: string, { paste = false }: { paste?: boolean } = {}): void => {
|
||||
if (typeof ch !== 'string') {
|
||||
@@ -3495,10 +3492,10 @@ export function useTextBuffer({
|
||||
const visualScrollRow = useMemo(() => {
|
||||
const totalVisualLines = visualLines.length;
|
||||
return Math.min(
|
||||
scrollRowState,
|
||||
actualScrollRowState,
|
||||
Math.max(0, totalVisualLines - viewport.height),
|
||||
);
|
||||
}, [visualLines.length, scrollRowState, viewport.height]);
|
||||
}, [visualLines.length, actualScrollRowState, viewport.height]);
|
||||
|
||||
const renderedVisualLines = useMemo(
|
||||
() => visualLines.slice(visualScrollRow, visualScrollRow + viewport.height),
|
||||
@@ -3694,6 +3691,7 @@ export function useTextBuffer({
|
||||
viewportVisualLines: renderedVisualLines,
|
||||
visualCursor,
|
||||
visualScrollRow,
|
||||
viewportHeight: viewport.height,
|
||||
visualToLogicalMap,
|
||||
transformedToLogicalMaps,
|
||||
visualToTransformedMap,
|
||||
@@ -3799,6 +3797,7 @@ export function useTextBuffer({
|
||||
renderedVisualLines,
|
||||
visualCursor,
|
||||
visualScrollRow,
|
||||
viewport.height,
|
||||
visualToLogicalMap,
|
||||
transformedToLogicalMaps,
|
||||
visualToTransformedMap,
|
||||
@@ -3914,6 +3913,7 @@ export interface TextBuffer {
|
||||
viewportVisualLines: string[]; // The subset of visual lines to be rendered based on visualScrollRow and viewport.height
|
||||
visualCursor: [number, number]; // Visual cursor [row, col] relative to the start of all visualLines
|
||||
visualScrollRow: number; // Scroll position for visual lines (index of the first visible visual line)
|
||||
viewportHeight: number; // The maximum height of the viewport
|
||||
/**
|
||||
* For each visual line (by absolute index in allVisualLines) provides a tuple
|
||||
* [logicalLineIndex, startColInLogical] that maps where that visual line
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { SandboxPolicyManager } from './sandboxPolicyManager.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
describe('SandboxPolicyManager', () => {
|
||||
const tempDir = path.join(os.tmpdir(), 'gemini-test-sandbox-policy');
|
||||
const configPath = path.join(tempDir, 'sandbox.toml');
|
||||
|
||||
beforeEach(() => {
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should add and retrieve session approvals', () => {
|
||||
const manager = new SandboxPolicyManager(configPath);
|
||||
manager.addSessionApproval('ls', {
|
||||
fileSystem: { read: ['/tmp'], write: [] },
|
||||
network: false,
|
||||
});
|
||||
|
||||
const perms = manager.getCommandPermissions('ls');
|
||||
expect(perms.fileSystem?.read).toContain('/tmp');
|
||||
});
|
||||
|
||||
it('should protect against prototype pollution (session)', () => {
|
||||
const manager = new SandboxPolicyManager(configPath);
|
||||
manager.addSessionApproval('__proto__', {
|
||||
fileSystem: { read: ['/POLLUTED'], write: [] },
|
||||
network: true,
|
||||
});
|
||||
|
||||
const perms = manager.getCommandPermissions('any-command');
|
||||
expect(perms.fileSystem?.read).not.toContain('/POLLUTED');
|
||||
});
|
||||
|
||||
it('should protect against prototype pollution (persistent)', () => {
|
||||
const manager = new SandboxPolicyManager(configPath);
|
||||
manager.addPersistentApproval('constructor', {
|
||||
fileSystem: { read: ['/POLLUTED_PERSISTENT'], write: [] },
|
||||
network: true,
|
||||
});
|
||||
|
||||
const perms = manager.getCommandPermissions('constructor');
|
||||
expect(perms.fileSystem?.read).not.toContain('/POLLUTED_PERSISTENT');
|
||||
});
|
||||
|
||||
it('should lowercase command names for normalization', () => {
|
||||
const manager = new SandboxPolicyManager(configPath);
|
||||
manager.addSessionApproval('NPM', {
|
||||
fileSystem: { read: ['/node_modules'], write: [] },
|
||||
network: true,
|
||||
});
|
||||
|
||||
const perms = manager.getCommandPermissions('npm');
|
||||
expect(perms.fileSystem?.read).toContain('/node_modules');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { type SandboxPermissions } from '../services/sandboxManager.js';
|
||||
import { sanitizePaths } from '../services/sandboxManager.js';
|
||||
import { normalizeCommand } from '../utils/shell-utils.js';
|
||||
|
||||
export const SandboxModeConfigSchema = z.object({
|
||||
network: z.boolean(),
|
||||
@@ -104,6 +105,10 @@ export class SandboxPolicyManager {
|
||||
this.config = this.loadConfig();
|
||||
}
|
||||
|
||||
private isProtectedKey(key: string): boolean {
|
||||
return key === '__proto__' || key === 'constructor' || key === 'prototype';
|
||||
}
|
||||
|
||||
private loadConfig(): SandboxTomlSchemaType {
|
||||
if (!fs.existsSync(this.configPath)) {
|
||||
return SandboxPolicyManager.DEFAULT_CONFIG;
|
||||
@@ -154,8 +159,15 @@ export class SandboxPolicyManager {
|
||||
}
|
||||
|
||||
getCommandPermissions(commandName: string): SandboxPermissions {
|
||||
const persistent = this.config.commands[commandName];
|
||||
const session = this.sessionApprovals[commandName];
|
||||
const normalized = normalizeCommand(commandName);
|
||||
if (this.isProtectedKey(normalized)) {
|
||||
return {
|
||||
fileSystem: { read: [], write: [] },
|
||||
network: false,
|
||||
};
|
||||
}
|
||||
const persistent = this.config.commands[normalized];
|
||||
const session = this.sessionApprovals[normalized];
|
||||
|
||||
return {
|
||||
fileSystem: {
|
||||
@@ -176,25 +188,25 @@ export class SandboxPolicyManager {
|
||||
commandName: string,
|
||||
permissions: SandboxPermissions,
|
||||
): void {
|
||||
const existing = this.sessionApprovals[commandName] || {
|
||||
const normalized = normalizeCommand(commandName);
|
||||
if (this.isProtectedKey(normalized)) {
|
||||
return;
|
||||
}
|
||||
const existing = this.sessionApprovals[normalized] || {
|
||||
fileSystem: { read: [], write: [] },
|
||||
network: false,
|
||||
};
|
||||
|
||||
this.sessionApprovals[commandName] = {
|
||||
this.sessionApprovals[normalized] = {
|
||||
fileSystem: {
|
||||
read: Array.from(
|
||||
new Set([
|
||||
...(existing.fileSystem?.read ?? []),
|
||||
...(permissions.fileSystem?.read ?? []),
|
||||
]),
|
||||
),
|
||||
write: Array.from(
|
||||
new Set([
|
||||
...(existing.fileSystem?.write ?? []),
|
||||
...(permissions.fileSystem?.write ?? []),
|
||||
]),
|
||||
),
|
||||
read: sanitizePaths([
|
||||
...(existing.fileSystem?.read ?? []),
|
||||
...(permissions.fileSystem?.read ?? []),
|
||||
]),
|
||||
write: sanitizePaths([
|
||||
...(existing.fileSystem?.write ?? []),
|
||||
...(permissions.fileSystem?.write ?? []),
|
||||
]),
|
||||
},
|
||||
network: existing.network || permissions.network || false,
|
||||
};
|
||||
@@ -204,7 +216,11 @@ export class SandboxPolicyManager {
|
||||
commandName: string,
|
||||
permissions: SandboxPermissions,
|
||||
): void {
|
||||
const existing = this.config.commands[commandName] || {
|
||||
const normalized = normalizeCommand(commandName);
|
||||
if (this.isProtectedKey(normalized)) {
|
||||
return;
|
||||
}
|
||||
const existing = this.config.commands[normalized] || {
|
||||
allowed_paths: [],
|
||||
allow_network: false,
|
||||
};
|
||||
@@ -216,7 +232,7 @@ export class SandboxPolicyManager {
|
||||
];
|
||||
const newPaths = new Set(sanitizePaths(newPathsArray));
|
||||
|
||||
this.config.commands[commandName] = {
|
||||
this.config.commands[normalized] = {
|
||||
allowed_paths: Array.from(newPaths),
|
||||
allow_network: existing.allow_network || permissions.network || false,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { type SandboxPermissions } from '../../services/sandboxManager.js';
|
||||
import { normalizeCommand } from '../../utils/shell-utils.js';
|
||||
|
||||
const NETWORK_RELIANT_TOOLS = new Set([
|
||||
'npm',
|
||||
@@ -45,7 +46,7 @@ export function isNetworkReliantCommand(
|
||||
commandName: string,
|
||||
subCommand?: string,
|
||||
): boolean {
|
||||
const normalizedCommand = commandName.toLowerCase().replace(/\.exe$/, '');
|
||||
const normalizedCommand = normalizeCommand(commandName);
|
||||
if (!NETWORK_RELIANT_TOOLS.has(normalizedCommand)) {
|
||||
return false;
|
||||
}
|
||||
@@ -82,7 +83,7 @@ export function isNetworkReliantCommand(
|
||||
export async function getProactiveToolSuggestions(
|
||||
commandName: string,
|
||||
): Promise<SandboxPermissions | undefined> {
|
||||
const normalizedCommand = commandName.toLowerCase().replace(/\.exe$/, '');
|
||||
const normalizedCommand = normalizeCommand(commandName);
|
||||
if (!NETWORK_RELIANT_TOOLS.has(normalizedCommand)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ using System.Text;
|
||||
*/
|
||||
public class GeminiSandbox {
|
||||
// P/Invoke constants and structures
|
||||
private const int JobObjectExtendedLimitInformation = 9;
|
||||
private const int JobObjectNetRateControlInformation = 32;
|
||||
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
|
||||
private const uint JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION = 0x00000400;
|
||||
private const uint JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008;
|
||||
@@ -74,6 +76,9 @@ public class GeminiSandbox {
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern uint ResumeThread(IntPtr hThread);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle);
|
||||
|
||||
@@ -191,7 +196,8 @@ public class GeminiSandbox {
|
||||
|
||||
IntPtr hToken = IntPtr.Zero;
|
||||
IntPtr hRestrictedToken = IntPtr.Zero;
|
||||
IntPtr lowIntegritySid = IntPtr.Zero;
|
||||
IntPtr hJob = IntPtr.Zero;
|
||||
PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
|
||||
|
||||
try {
|
||||
// 1. Duplicate Primary Token
|
||||
@@ -208,6 +214,7 @@ public class GeminiSandbox {
|
||||
|
||||
// 2. Lower Integrity Level to Low
|
||||
// S-1-16-4096 is the SID for "Low Mandatory Level"
|
||||
IntPtr lowIntegritySid = IntPtr.Zero;
|
||||
if (ConvertStringSidToSid("S-1-16-4096", out lowIntegritySid)) {
|
||||
TOKEN_MANDATORY_LABEL tml = new TOKEN_MANDATORY_LABEL();
|
||||
tml.Label.Sid = lowIntegritySid;
|
||||
@@ -226,25 +233,42 @@ public class GeminiSandbox {
|
||||
}
|
||||
|
||||
// 3. Setup Job Object for cleanup
|
||||
IntPtr hJob = CreateJobObject(IntPtr.Zero, null);
|
||||
hJob = CreateJobObject(IntPtr.Zero, null);
|
||||
if (hJob == IntPtr.Zero) {
|
||||
Console.Error.WriteLine("Error: CreateJobObject failed (" + Marshal.GetLastWin32Error() + ")");
|
||||
return 1;
|
||||
}
|
||||
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobLimits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
|
||||
jobLimits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION;
|
||||
|
||||
|
||||
IntPtr lpJobLimits = Marshal.AllocHGlobal(Marshal.SizeOf(jobLimits));
|
||||
Marshal.StructureToPtr(jobLimits, lpJobLimits, false);
|
||||
SetInformationJobObject(hJob, 9 /* JobObjectExtendedLimitInformation */, lpJobLimits, (uint)Marshal.SizeOf(jobLimits));
|
||||
Marshal.FreeHGlobal(lpJobLimits);
|
||||
try {
|
||||
Marshal.StructureToPtr(jobLimits, lpJobLimits, false);
|
||||
if (!SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, lpJobLimits, (uint)Marshal.SizeOf(jobLimits))) {
|
||||
Console.Error.WriteLine("Error: SetInformationJobObject(Limits) failed (" + Marshal.GetLastWin32Error() + ")");
|
||||
return 1;
|
||||
}
|
||||
} finally {
|
||||
Marshal.FreeHGlobal(lpJobLimits);
|
||||
}
|
||||
|
||||
if (!networkAccess) {
|
||||
JOBOBJECT_NET_RATE_CONTROL_INFORMATION netLimits = new JOBOBJECT_NET_RATE_CONTROL_INFORMATION();
|
||||
netLimits.MaxBandwidth = 1;
|
||||
netLimits.ControlFlags = 0x1 | 0x2; // ENABLE | MAX_BANDWIDTH
|
||||
netLimits.DscpTag = 0;
|
||||
|
||||
|
||||
IntPtr lpNetLimits = Marshal.AllocHGlobal(Marshal.SizeOf(netLimits));
|
||||
Marshal.StructureToPtr(netLimits, lpNetLimits, false);
|
||||
SetInformationJobObject(hJob, 32 /* JobObjectNetRateControlInformation */, lpNetLimits, (uint)Marshal.SizeOf(netLimits));
|
||||
Marshal.FreeHGlobal(lpNetLimits);
|
||||
try {
|
||||
Marshal.StructureToPtr(netLimits, lpNetLimits, false);
|
||||
if (!SetInformationJobObject(hJob, JobObjectNetRateControlInformation, lpNetLimits, (uint)Marshal.SizeOf(netLimits))) {
|
||||
// Some versions of Windows might not support network rate control, but we should know if it fails.
|
||||
Console.Error.WriteLine("Warning: SetInformationJobObject(NetRate) failed (" + Marshal.GetLastWin32Error() + "). Network might not be throttled.");
|
||||
}
|
||||
} finally {
|
||||
Marshal.FreeHGlobal(lpNetLimits);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Handle Internal Commands or External Process
|
||||
@@ -310,32 +334,49 @@ public class GeminiSandbox {
|
||||
commandLine += QuoteArgument(args[i]);
|
||||
}
|
||||
|
||||
PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
|
||||
// Creation Flags: 0x04000000 (CREATE_BREAKAWAY_FROM_JOB) to allow job assignment if parent is in job
|
||||
uint creationFlags = 0;
|
||||
// Creation Flags: 0x01000000 (CREATE_BREAKAWAY_FROM_JOB) to allow job assignment if parent is in job
|
||||
// 0x00000004 (CREATE_SUSPENDED) to prevent the process from executing before being placed in the job
|
||||
uint creationFlags = 0x01000000 | 0x00000004;
|
||||
if (!CreateProcessAsUser(hRestrictedToken, null, commandLine, IntPtr.Zero, IntPtr.Zero, true, creationFlags, IntPtr.Zero, cwd, ref si, out pi)) {
|
||||
Console.WriteLine("Error: CreateProcessAsUser failed (" + Marshal.GetLastWin32Error() + ") Command: " + commandLine);
|
||||
int err = Marshal.GetLastWin32Error();
|
||||
Console.Error.WriteLine("Error: CreateProcessAsUser failed (" + err + ") Command: " + commandLine);
|
||||
return 1;
|
||||
}
|
||||
|
||||
AssignProcessToJobObject(hJob, pi.hProcess);
|
||||
|
||||
// Wait for exit
|
||||
uint waitResult = WaitForSingleObject(pi.hProcess, 0xFFFFFFFF);
|
||||
uint exitCode = 0;
|
||||
GetExitCodeProcess(pi.hProcess, out exitCode);
|
||||
if (!AssignProcessToJobObject(hJob, pi.hProcess)) {
|
||||
int err = Marshal.GetLastWin32Error();
|
||||
Console.Error.WriteLine("Error: AssignProcessToJobObject failed (" + err + ") Command: " + commandLine);
|
||||
TerminateProcess(pi.hProcess, 1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(pi.hThread);
|
||||
CloseHandle(hJob);
|
||||
ResumeThread(pi.hThread);
|
||||
|
||||
if (WaitForSingleObject(pi.hProcess, 0xFFFFFFFF) == 0xFFFFFFFF) {
|
||||
int err = Marshal.GetLastWin32Error();
|
||||
Console.Error.WriteLine("Error: WaitForSingleObject failed (" + err + ")");
|
||||
}
|
||||
|
||||
uint exitCode = 0;
|
||||
if (!GetExitCodeProcess(pi.hProcess, out exitCode)) {
|
||||
int err = Marshal.GetLastWin32Error();
|
||||
Console.Error.WriteLine("Error: GetExitCodeProcess failed (" + err + ")");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (int)exitCode;
|
||||
} finally {
|
||||
if (hToken != IntPtr.Zero) CloseHandle(hToken);
|
||||
if (hRestrictedToken != IntPtr.Zero) CloseHandle(hRestrictedToken);
|
||||
if (hJob != IntPtr.Zero) CloseHandle(hJob);
|
||||
if (pi.hProcess != IntPtr.Zero) CloseHandle(pi.hProcess);
|
||||
if (pi.hThread != IntPtr.Zero) CloseHandle(pi.hThread);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
|
||||
|
||||
|
||||
@@ -25,17 +25,40 @@ vi.mock('../../utils/shell-utils.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// TODO: reenable once test is fixed
|
||||
describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
describe('WindowsSandboxManager', () => {
|
||||
let manager: WindowsSandboxManager;
|
||||
let testCwd: string;
|
||||
|
||||
/**
|
||||
* Creates a temporary directory and returns its canonical real path.
|
||||
*/
|
||||
function createTempDir(name: string, parent = os.tmpdir()): string {
|
||||
const rawPath = fs.mkdtempSync(path.join(parent, `gemini-test-${name}-`));
|
||||
return fs.realpathSync(rawPath);
|
||||
}
|
||||
|
||||
const helperExePath = path.resolve(
|
||||
__dirname,
|
||||
WindowsSandboxManager.HELPER_EXE,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
|
||||
p.toString(),
|
||||
);
|
||||
testCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-'));
|
||||
|
||||
// Mock existsSync to skip the csc.exe auto-compilation of helper during unit tests.
|
||||
const originalExistsSync = fs.existsSync;
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation((p) => {
|
||||
if (typeof p === 'string' && path.resolve(p) === helperExePath) {
|
||||
return true;
|
||||
}
|
||||
return originalExistsSync(p);
|
||||
});
|
||||
|
||||
testCwd = createTempDir('cwd');
|
||||
|
||||
manager = new WindowsSandboxManager({
|
||||
workspace: testCwd,
|
||||
modeConfig: { readonly: false, allowOverrides: true },
|
||||
@@ -45,7 +68,9 @@ describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(testCwd, { recursive: true, force: true });
|
||||
if (testCwd && fs.existsSync(testCwd)) {
|
||||
fs.rmSync(testCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should prepare a GeminiSandbox.exe command', async () => {
|
||||
@@ -155,8 +180,7 @@ describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
});
|
||||
|
||||
it('should handle persistent permissions from policyManager', async () => {
|
||||
const persistentPath = path.join(testCwd, 'persistent_path');
|
||||
fs.mkdirSync(persistentPath, { recursive: true });
|
||||
const persistentPath = createTempDir('persistent', testCwd);
|
||||
|
||||
const mockPolicyManager = {
|
||||
getCommandPermissions: vi.fn().mockReturnValue({
|
||||
@@ -189,6 +213,8 @@ describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
|
||||
expect(icaclsArgs).toContainEqual([
|
||||
persistentPath,
|
||||
'/grant',
|
||||
'*S-1-16-4096:(OI)(CI)(M)',
|
||||
'/setintegritylevel',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
@@ -234,10 +260,7 @@ describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
});
|
||||
|
||||
it('should grant Low Integrity access to the workspace and allowed paths', async () => {
|
||||
const allowedPath = path.join(os.tmpdir(), 'gemini-cli-test-allowed');
|
||||
if (!fs.existsSync(allowedPath)) {
|
||||
fs.mkdirSync(allowedPath);
|
||||
}
|
||||
const allowedPath = createTempDir('allowed');
|
||||
try {
|
||||
const req: SandboxRequest = {
|
||||
command: 'test',
|
||||
@@ -257,13 +280,17 @@ describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
.map((c) => c[1]);
|
||||
|
||||
expect(icaclsArgs).toContainEqual([
|
||||
path.resolve(testCwd),
|
||||
testCwd,
|
||||
'/grant',
|
||||
'*S-1-16-4096:(OI)(CI)(M)',
|
||||
'/setintegritylevel',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
|
||||
expect(icaclsArgs).toContainEqual([
|
||||
path.resolve(allowedPath),
|
||||
allowedPath,
|
||||
'/grant',
|
||||
'*S-1-16-4096:(OI)(CI)(M)',
|
||||
'/setintegritylevel',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
@@ -273,13 +300,7 @@ describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
});
|
||||
|
||||
it('should grant Low Integrity access to additional write paths', async () => {
|
||||
const extraWritePath = path.join(
|
||||
os.tmpdir(),
|
||||
'gemini-cli-test-extra-write',
|
||||
);
|
||||
if (!fs.existsSync(extraWritePath)) {
|
||||
fs.mkdirSync(extraWritePath);
|
||||
}
|
||||
const extraWritePath = createTempDir('extra-write');
|
||||
try {
|
||||
const req: SandboxRequest = {
|
||||
command: 'test',
|
||||
@@ -303,7 +324,9 @@ describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
.map((c) => c[1]);
|
||||
|
||||
expect(icaclsArgs).toContainEqual([
|
||||
path.resolve(extraWritePath),
|
||||
extraWritePath,
|
||||
'/grant',
|
||||
'*S-1-16-4096:(OI)(CI)(M)',
|
||||
'/setintegritylevel',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
@@ -330,26 +353,26 @@ describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
},
|
||||
};
|
||||
|
||||
await manager.prepareCommand(req);
|
||||
// Rejected because it's an unreachable/invalid UNC path or it doesn't exist
|
||||
await expect(manager.prepareCommand(req)).rejects.toThrow();
|
||||
|
||||
const icaclsArgs = vi
|
||||
.mocked(spawnAsync)
|
||||
.mock.calls.filter((c) => c[0] === 'icacls')
|
||||
.map((c) => c[1]);
|
||||
|
||||
expect(icaclsArgs).not.toContainEqual([
|
||||
uncPath,
|
||||
'/setintegritylevel',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
expect(icaclsArgs).not.toContainEqual(expect.arrayContaining([uncPath]));
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === 'win32')(
|
||||
'should allow extended-length and local device paths',
|
||||
async () => {
|
||||
const longPath = '\\\\?\\C:\\very\\long\\path';
|
||||
const devicePath = '\\\\.\\PhysicalDrive0';
|
||||
// Create actual files for inheritance/existence checks
|
||||
const longPath = path.join(testCwd, 'very_long_path.txt');
|
||||
const devicePath = path.join(testCwd, 'device_path.txt');
|
||||
fs.writeFileSync(longPath, '');
|
||||
fs.writeFileSync(devicePath, '');
|
||||
|
||||
const req: SandboxRequest = {
|
||||
command: 'test',
|
||||
@@ -373,12 +396,16 @@ describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
.map((c) => c[1]);
|
||||
|
||||
expect(icaclsArgs).toContainEqual([
|
||||
longPath,
|
||||
path.resolve(longPath),
|
||||
'/grant',
|
||||
'*S-1-16-4096:(OI)(CI)(M)',
|
||||
'/setintegritylevel',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
expect(icaclsArgs).toContainEqual([
|
||||
devicePath,
|
||||
path.resolve(devicePath),
|
||||
'/grant',
|
||||
'*S-1-16-4096:(OI)(CI)(M)',
|
||||
'/setintegritylevel',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
@@ -420,10 +447,7 @@ describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
});
|
||||
|
||||
it('should deny Low Integrity access to forbidden paths', async () => {
|
||||
const forbiddenPath = path.join(os.tmpdir(), 'gemini-cli-test-forbidden');
|
||||
if (!fs.existsSync(forbiddenPath)) {
|
||||
fs.mkdirSync(forbiddenPath);
|
||||
}
|
||||
const forbiddenPath = createTempDir('forbidden');
|
||||
try {
|
||||
const managerWithForbidden = new WindowsSandboxManager({
|
||||
workspace: testCwd,
|
||||
@@ -440,7 +464,7 @@ describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
await managerWithForbidden.prepareCommand(req);
|
||||
|
||||
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
|
||||
path.resolve(forbiddenPath),
|
||||
forbiddenPath,
|
||||
'/deny',
|
||||
'*S-1-16-4096:(OI)(CI)(F)',
|
||||
]);
|
||||
@@ -450,10 +474,7 @@ describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
});
|
||||
|
||||
it('should override allowed paths if a path is also in forbidden paths', async () => {
|
||||
const conflictPath = path.join(os.tmpdir(), 'gemini-cli-test-conflict');
|
||||
if (!fs.existsSync(conflictPath)) {
|
||||
fs.mkdirSync(conflictPath);
|
||||
}
|
||||
const conflictPath = createTempDir('conflict');
|
||||
try {
|
||||
const managerWithForbidden = new WindowsSandboxManager({
|
||||
workspace: testCwd,
|
||||
@@ -478,14 +499,14 @@ describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
call[1] &&
|
||||
call[1].includes('/setintegritylevel') &&
|
||||
call[0] === 'icacls' &&
|
||||
call[1][0] === path.resolve(conflictPath),
|
||||
call[1][0] === conflictPath,
|
||||
);
|
||||
const denyCallIndex = spawnMock.mock.calls.findIndex(
|
||||
(call) =>
|
||||
call[1] &&
|
||||
call[1].includes('/deny') &&
|
||||
call[0] === 'icacls' &&
|
||||
call[1][0] === path.resolve(conflictPath),
|
||||
call[1][0] === conflictPath,
|
||||
);
|
||||
|
||||
// Conflict should have been filtered out of allow calls
|
||||
@@ -513,8 +534,8 @@ describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
expect(result.args[5]).toBe(filePath);
|
||||
});
|
||||
|
||||
it('should safely handle special characters in __write path', async () => {
|
||||
const maliciousPath = path.join(testCwd, 'foo"; echo bar; ".txt');
|
||||
it('should safely handle special characters in __write path using environment variables', async () => {
|
||||
const maliciousPath = path.join(testCwd, 'foo & echo bar; ! .txt');
|
||||
fs.writeFileSync(maliciousPath, '');
|
||||
const req: SandboxRequest = {
|
||||
command: '__write',
|
||||
@@ -545,4 +566,23 @@ describe.skipIf(os.platform() === 'win32')('WindowsSandboxManager', () => {
|
||||
expect(result.args[4]).toBe('__read');
|
||||
expect(result.args[5]).toBe(filePath);
|
||||
});
|
||||
|
||||
it('should return a cleanup function that deletes the temporary manifest', async () => {
|
||||
const req: SandboxRequest = {
|
||||
command: 'test',
|
||||
args: [],
|
||||
cwd: testCwd,
|
||||
env: {},
|
||||
};
|
||||
|
||||
const result = await manager.prepareCommand(req);
|
||||
const manifestPath = result.args[3];
|
||||
|
||||
expect(fs.existsSync(manifestPath)).toBe(true);
|
||||
expect(result.cleanup).toBeDefined();
|
||||
|
||||
result.cleanup?.();
|
||||
expect(fs.existsSync(manifestPath)).toBe(false);
|
||||
expect(fs.existsSync(path.dirname(manifestPath))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
findSecretFiles,
|
||||
type GlobalSandboxOptions,
|
||||
sanitizePaths,
|
||||
tryRealpath,
|
||||
type SandboxPermissions,
|
||||
type ParsedSandboxDenial,
|
||||
resolveSandboxPaths,
|
||||
@@ -36,23 +35,28 @@ import {
|
||||
} from './commandSafety.js';
|
||||
import { verifySandboxOverrides } from '../utils/commandUtils.js';
|
||||
import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js';
|
||||
import { isSubpath, resolveToRealPath } from '../../utils/paths.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// S-1-16-4096 is the SID for "Low Mandatory Level" (Low Integrity)
|
||||
const LOW_INTEGRITY_SID = '*S-1-16-4096';
|
||||
|
||||
/**
|
||||
* A SandboxManager implementation for Windows that uses Restricted Tokens,
|
||||
* Job Objects, and Low Integrity levels for process isolation.
|
||||
* Uses a native C# helper to bypass PowerShell restrictions.
|
||||
*/
|
||||
export class WindowsSandboxManager implements SandboxManager {
|
||||
static readonly HELPER_EXE = 'GeminiSandbox.exe';
|
||||
private readonly helperPath: string;
|
||||
private initialized = false;
|
||||
private readonly allowedCache = new Set<string>();
|
||||
private readonly deniedCache = new Set<string>();
|
||||
|
||||
constructor(private readonly options: GlobalSandboxOptions) {
|
||||
this.helperPath = path.resolve(__dirname, 'GeminiSandbox.exe');
|
||||
this.helperPath = path.resolve(__dirname, WindowsSandboxManager.HELPER_EXE);
|
||||
}
|
||||
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
@@ -259,9 +263,14 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
|
||||
const networkAccess = defaultNetwork || mergedAdditional.network;
|
||||
|
||||
// 1. Handle filesystem permissions for Low Integrity
|
||||
// Grant "Low Mandatory Level" write access to the workspace.
|
||||
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
|
||||
const { allowed: allowedPaths, forbidden: forbiddenPaths } =
|
||||
await resolveSandboxPaths(this.options, req);
|
||||
|
||||
// Track all roots where Low Integrity write access has been granted.
|
||||
// New files created within these roots will inherit the Low label.
|
||||
const writableRoots: string[] = [];
|
||||
|
||||
// 1. Workspace access
|
||||
const isApproved = allowOverrides
|
||||
? await isStrictlyApproved(
|
||||
command,
|
||||
@@ -272,20 +281,19 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
|
||||
if (!isReadonlyMode || isApproved) {
|
||||
await this.grantLowIntegrityAccess(this.options.workspace);
|
||||
writableRoots.push(this.options.workspace);
|
||||
}
|
||||
|
||||
const { allowed: allowedPaths, forbidden: forbiddenPaths } =
|
||||
await resolveSandboxPaths(this.options, req);
|
||||
|
||||
// Grant "Low Mandatory Level" access to includeDirectories.
|
||||
// 2. Globally included directories
|
||||
const includeDirs = sanitizePaths(this.options.includeDirectories);
|
||||
for (const includeDir of includeDirs) {
|
||||
await this.grantLowIntegrityAccess(includeDir);
|
||||
writableRoots.push(includeDir);
|
||||
}
|
||||
|
||||
// Grant "Low Mandatory Level" read/write access to allowedPaths.
|
||||
// 3. Explicitly allowed paths from the request policy
|
||||
for (const allowedPath of allowedPaths) {
|
||||
const resolved = await tryRealpath(allowedPath);
|
||||
const resolved = resolveToRealPath(allowedPath);
|
||||
try {
|
||||
await fs.promises.access(resolved, fs.constants.F_OK);
|
||||
} catch {
|
||||
@@ -295,23 +303,32 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
);
|
||||
}
|
||||
await this.grantLowIntegrityAccess(resolved);
|
||||
writableRoots.push(resolved);
|
||||
}
|
||||
|
||||
// Grant "Low Mandatory Level" write access to additional permissions write paths.
|
||||
// 4. Additional write paths (e.g. from internal __write command)
|
||||
const additionalWritePaths = sanitizePaths(
|
||||
mergedAdditional.fileSystem?.write,
|
||||
);
|
||||
for (const writePath of additionalWritePaths) {
|
||||
const resolved = await tryRealpath(writePath);
|
||||
const resolved = resolveToRealPath(writePath);
|
||||
try {
|
||||
await fs.promises.access(resolved, fs.constants.F_OK);
|
||||
await this.grantLowIntegrityAccess(resolved);
|
||||
continue;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Sandbox request rejected: Additional write path does not exist: ${resolved}. ` +
|
||||
'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.',
|
||||
// If the file doesn't exist, it's only allowed if it resides within a granted root.
|
||||
const isInherited = writableRoots.some((root) =>
|
||||
isSubpath(root, resolved),
|
||||
);
|
||||
|
||||
if (!isInherited) {
|
||||
throw new Error(
|
||||
`Sandbox request rejected: Additional write path does not exist and its parent directory is not allowed: ${resolved}. ` +
|
||||
'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.grantLowIntegrityAccess(resolved);
|
||||
}
|
||||
|
||||
// 2. Collect secret files and apply protective ACLs
|
||||
@@ -382,15 +399,6 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
const manifestPath = path.join(tempDir, 'manifest.txt');
|
||||
fs.writeFileSync(manifestPath, allForbidden.join('\n'));
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('exit', () => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Construct the helper command
|
||||
// GeminiSandbox.exe <network:0|1> <cwd> --forbidden-manifest <path> <command> [args...]
|
||||
const program = this.helperPath;
|
||||
@@ -411,6 +419,13 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
args: finalArgs,
|
||||
env: finalEnv,
|
||||
cwd: req.cwd,
|
||||
cleanup: () => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -422,7 +437,7 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = await tryRealpath(targetPath);
|
||||
const resolvedPath = resolveToRealPath(targetPath);
|
||||
if (this.allowedCache.has(resolvedPath)) {
|
||||
return;
|
||||
}
|
||||
@@ -446,8 +461,12 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Grant explicit Modify access to the Low Integrity SID
|
||||
// 2. Set the Mandatory Label to Low to allow "Write Up" from Low processes
|
||||
await spawnAsync('icacls', [
|
||||
resolvedPath,
|
||||
'/grant',
|
||||
`${LOW_INTEGRITY_SID}:(OI)(CI)(M)`,
|
||||
'/setintegritylevel',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
@@ -469,7 +488,7 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = await tryRealpath(targetPath);
|
||||
const resolvedPath = resolveToRealPath(targetPath);
|
||||
if (this.deniedCache.has(resolvedPath)) {
|
||||
return;
|
||||
}
|
||||
@@ -479,9 +498,6 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// S-1-16-4096 is the SID for "Low Mandatory Level" (Low Integrity)
|
||||
const LOW_INTEGRITY_SID = '*S-1-16-4096';
|
||||
|
||||
// icacls flags: (OI) Object Inherit, (CI) Container Inherit, (F) Full Access Deny.
|
||||
// Omit /T (recursive) for performance; (OI)(CI) ensures inheritance for new items.
|
||||
// Windows dynamically evaluates existing items, though deep explicit Allow ACEs
|
||||
|
||||
@@ -28,7 +28,14 @@ const Platform = {
|
||||
/** Returns a command to create an empty file. */
|
||||
touch(filePath: string) {
|
||||
return this.isWindows
|
||||
? { command: 'cmd.exe', args: ['/c', `type nul > "${filePath}"`] }
|
||||
? {
|
||||
command: 'powershell.exe',
|
||||
args: [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`New-Item -Path "${filePath}" -ItemType File -Force`,
|
||||
],
|
||||
}
|
||||
: { command: 'touch', args: [filePath] };
|
||||
},
|
||||
|
||||
@@ -48,18 +55,13 @@ const Platform = {
|
||||
|
||||
/** Returns a command to perform a network request. */
|
||||
curl(url: string) {
|
||||
return this.isWindows
|
||||
? {
|
||||
command: 'powershell.exe',
|
||||
args: ['-Command', `Invoke-WebRequest -Uri ${url} -TimeoutSec 1`],
|
||||
}
|
||||
: { command: 'curl', args: ['-s', '--connect-timeout', '1', url] };
|
||||
return { command: 'curl', args: ['-s', '--connect-timeout', '1', url] };
|
||||
},
|
||||
|
||||
/** Returns a command that checks if the current terminal is interactive. */
|
||||
isPty() {
|
||||
return this.isWindows
|
||||
? 'cmd.exe /c echo True'
|
||||
? 'powershell.exe -NoProfile -Command "echo True"'
|
||||
: 'bash -c "if [ -t 1 ]; then echo True; else echo False; fi"';
|
||||
},
|
||||
|
||||
@@ -103,8 +105,7 @@ function ensureSandboxAvailable(): boolean {
|
||||
if (platform === 'win32') {
|
||||
// Windows sandboxing relies on icacls, which is a core system utility and
|
||||
// always available.
|
||||
// TODO: reenable once test is fixed
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
@@ -167,23 +168,28 @@ describe('SandboxManager Integration', () => {
|
||||
expect(result.stdout.trim()).toBe('sandbox test');
|
||||
});
|
||||
|
||||
it('supports interactive pseudo-terminals (node-pty)', async () => {
|
||||
const handle = await ShellExecutionService.execute(
|
||||
Platform.isPty(),
|
||||
workspace,
|
||||
() => {},
|
||||
new AbortController().signal,
|
||||
true,
|
||||
{
|
||||
sanitizationConfig: getSecureSanitizationConfig(),
|
||||
sandboxManager: manager,
|
||||
},
|
||||
);
|
||||
// The Windows sandbox wrapper (GeminiSandbox.exe) uses standard pipes
|
||||
// for I/O interception, which breaks ConPTY pseudo-terminal inheritance.
|
||||
it.skipIf(Platform.isWindows)(
|
||||
'supports interactive pseudo-terminals (node-pty)',
|
||||
async () => {
|
||||
const handle = await ShellExecutionService.execute(
|
||||
Platform.isPty(),
|
||||
workspace,
|
||||
() => {},
|
||||
new AbortController().signal,
|
||||
true,
|
||||
{
|
||||
sanitizationConfig: getSecureSanitizationConfig(),
|
||||
sandboxManager: manager,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain('True');
|
||||
});
|
||||
const result = await handle.result;
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain('True');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('File System Access', () => {
|
||||
@@ -511,18 +517,23 @@ describe('SandboxManager Integration', () => {
|
||||
if (server) await new Promise<void>((res) => server.close(() => res()));
|
||||
});
|
||||
|
||||
it('blocks network access by default', async () => {
|
||||
const { command, args } = Platform.curl(url);
|
||||
const sandboxed = await manager.prepareCommand({
|
||||
command,
|
||||
args,
|
||||
cwd: workspace,
|
||||
env: process.env,
|
||||
});
|
||||
// Windows Job Object rate limits exempt loopback (127.0.0.1) traffic,
|
||||
// so this test cannot verify loopback blocking on Windows.
|
||||
it.skipIf(Platform.isWindows)(
|
||||
'blocks network access by default',
|
||||
async () => {
|
||||
const { command, args } = Platform.curl(url);
|
||||
const sandboxed = await manager.prepareCommand({
|
||||
command,
|
||||
args,
|
||||
cwd: workspace,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const result = await runCommand(sandboxed);
|
||||
expect(result.status).not.toBe(0);
|
||||
});
|
||||
const result = await runCommand(sandboxed);
|
||||
expect(result.status).not.toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('grants network access when explicitly allowed', async () => {
|
||||
const { command, args } = Platform.curl(url);
|
||||
|
||||
@@ -154,7 +154,11 @@ describe('ShellTool', () => {
|
||||
return mockSandboxManager;
|
||||
},
|
||||
sandboxPolicyManager: {
|
||||
getCommandPermissions: vi.fn().mockReturnValue(undefined),
|
||||
getCommandPermissions: vi.fn().mockReturnValue({
|
||||
fileSystem: { read: [], write: [] },
|
||||
network: false,
|
||||
}),
|
||||
|
||||
getModeConfig: vi.fn().mockReturnValue({ readonly: false }),
|
||||
addPersistentApproval: vi.fn(),
|
||||
addSessionApproval: vi.fn(),
|
||||
@@ -708,6 +712,39 @@ describe('ShellTool', () => {
|
||||
it('should throw an error if validation fails', () => {
|
||||
expect(() => shellTool.build({ command: '' })).toThrow();
|
||||
});
|
||||
|
||||
it('should NOT return a sandbox expansion prompt for npm install when sandboxing is disabled', async () => {
|
||||
const bus = (shellTool as unknown as { messageBus: MessageBus })
|
||||
.messageBus;
|
||||
const mockBus = getMockMessageBusInstance(
|
||||
bus,
|
||||
) as unknown as TestableMockMessageBus;
|
||||
mockBus.defaultToolDecision = 'allow';
|
||||
|
||||
vi.mocked(mockConfig.getSandboxEnabled).mockReturnValue(false);
|
||||
const params = { command: 'npm install' };
|
||||
const invocation = shellTool.build(params);
|
||||
|
||||
const confirmation = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
// Should be false because standard confirm mode is 'allow'
|
||||
expect(confirmation).toBe(false);
|
||||
});
|
||||
|
||||
it('should return a sandbox expansion prompt for npm install when sandboxing is enabled', async () => {
|
||||
vi.mocked(mockConfig.getSandboxEnabled).mockReturnValue(true);
|
||||
const params = { command: 'npm install' };
|
||||
const invocation = shellTool.build(params);
|
||||
|
||||
const confirmation = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(confirmation).not.toBe(false);
|
||||
expect(confirmation && confirmation.type).toBe('sandbox_expansion');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDescription', () => {
|
||||
@@ -950,6 +987,10 @@ describe('ShellTool', () => {
|
||||
describe('sandbox heuristics', () => {
|
||||
const mockAbortSignal = new AbortController().signal;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockConfig.getSandboxEnabled).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should suggest proactive permissions for npm commands', async () => {
|
||||
const homeDir = path.join(tempRootDir, 'home');
|
||||
fs.mkdirSync(homeDir);
|
||||
|
||||
@@ -10,7 +10,10 @@ import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import crypto from 'node:crypto';
|
||||
import { debugLogger } from '../index.js';
|
||||
import type { SandboxPermissions } from '../services/sandboxManager.js';
|
||||
import {
|
||||
type SandboxPermissions,
|
||||
getPathIdentity,
|
||||
} from '../services/sandboxManager.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
@@ -44,6 +47,7 @@ import {
|
||||
parseCommandDetails,
|
||||
hasRedirection,
|
||||
isNakedSensitiveCommand,
|
||||
normalizeCommand,
|
||||
} from '../utils/shell-utils.js';
|
||||
import { buildParamArgsPattern } from '../policy/utils.js';
|
||||
import { SHELL_TOOL_NAME } from './tool-names.js';
|
||||
@@ -52,7 +56,7 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { getShellDefinition } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { isSubpath } from '../utils/paths.js';
|
||||
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
|
||||
import {
|
||||
getProactiveToolSuggestions,
|
||||
isNetworkReliantCommand,
|
||||
@@ -260,77 +264,103 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
return this.getConfirmationDetails(abortSignal);
|
||||
}
|
||||
|
||||
// Proactively suggest expansion for known network-heavy Node.js ecosystem tools
|
||||
// (npm install, etc.) to avoid hangs when network is restricted by default.
|
||||
// We do this even if the command is "allowed" by policy because the DEFAULT
|
||||
// permissions are usually insufficient for these commands.
|
||||
const command = stripShellWrapper(this.params.command);
|
||||
const rootCommands = getCommandRoots(command);
|
||||
const rootCommand = rootCommands[0];
|
||||
if (this.context.config.getSandboxEnabled()) {
|
||||
const command = stripShellWrapper(this.params.command);
|
||||
const rootCommands = getCommandRoots(command);
|
||||
const rawRootCommand = rootCommands[0];
|
||||
|
||||
if (rootCommand) {
|
||||
const proactive = await getProactiveToolSuggestions(rootCommand);
|
||||
if (proactive) {
|
||||
const approved =
|
||||
this.context.config.sandboxPolicyManager.getCommandPermissions(
|
||||
rootCommand,
|
||||
);
|
||||
const missingNetwork = !!proactive.network && !approved?.network;
|
||||
|
||||
// Detect commands or sub-commands that definitely need network
|
||||
const parsed = parseCommandDetails(command);
|
||||
const subCommand = parsed?.details[0]?.args?.[0];
|
||||
const needsNetwork = isNetworkReliantCommand(rootCommand, subCommand);
|
||||
|
||||
if (needsNetwork) {
|
||||
// Add write permission to the current directory if we are in readonly mode
|
||||
if (rawRootCommand) {
|
||||
const rootCommand = normalizeCommand(rawRootCommand);
|
||||
const proactive = await getProactiveToolSuggestions(rootCommand);
|
||||
if (proactive) {
|
||||
const mode = this.context.config.getApprovalMode();
|
||||
const isReadonlyMode =
|
||||
this.context.config.sandboxPolicyManager.getModeConfig(mode)
|
||||
?.readonly ?? false;
|
||||
const modeConfig =
|
||||
this.context.config.sandboxPolicyManager.getModeConfig(mode);
|
||||
const approved =
|
||||
this.context.config.sandboxPolicyManager.getCommandPermissions(
|
||||
rootCommand,
|
||||
);
|
||||
|
||||
if (isReadonlyMode) {
|
||||
const cwd =
|
||||
this.params.dir_path || this.context.config.getTargetDir();
|
||||
proactive.fileSystem = proactive.fileSystem || {
|
||||
read: [],
|
||||
write: [],
|
||||
};
|
||||
proactive.fileSystem.write = proactive.fileSystem.write || [];
|
||||
if (!proactive.fileSystem.write.includes(cwd)) {
|
||||
proactive.fileSystem.write.push(cwd);
|
||||
proactive.fileSystem.read = proactive.fileSystem.read || [];
|
||||
if (!proactive.fileSystem.read.includes(cwd)) {
|
||||
proactive.fileSystem.read.push(cwd);
|
||||
const hasNetwork = modeConfig.network || approved.network;
|
||||
const missingNetwork = !!proactive.network && !hasNetwork;
|
||||
|
||||
// Detect commands or sub-commands that definitely need network
|
||||
const parsed = parseCommandDetails(command);
|
||||
const subCommand = parsed?.details[0]?.args?.[0];
|
||||
const needsNetwork = isNetworkReliantCommand(rootCommand, subCommand);
|
||||
|
||||
if (needsNetwork) {
|
||||
// Add write permission to the current directory if we are in readonly mode
|
||||
const isReadonlyMode = modeConfig.readonly ?? false;
|
||||
|
||||
if (isReadonlyMode) {
|
||||
const cwd =
|
||||
this.params.dir_path || this.context.config.getTargetDir();
|
||||
proactive.fileSystem = proactive.fileSystem || {
|
||||
read: [],
|
||||
write: [],
|
||||
};
|
||||
proactive.fileSystem.write = proactive.fileSystem.write || [];
|
||||
if (!proactive.fileSystem.write.includes(cwd)) {
|
||||
proactive.fileSystem.write.push(cwd);
|
||||
proactive.fileSystem.read = proactive.fileSystem.read || [];
|
||||
if (!proactive.fileSystem.read.includes(cwd)) {
|
||||
proactive.fileSystem.read.push(cwd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const missingRead = (proactive.fileSystem?.read || []).filter(
|
||||
(p) => !approved?.fileSystem?.read?.includes(p),
|
||||
);
|
||||
const missingWrite = (proactive.fileSystem?.write || []).filter(
|
||||
(p) => !approved?.fileSystem?.write?.includes(p),
|
||||
);
|
||||
const isApproved = (
|
||||
requestedPath: string,
|
||||
approvedPaths?: string[],
|
||||
): boolean => {
|
||||
if (!approvedPaths || approvedPaths.length === 0) return false;
|
||||
const requestedRealIdentity = getPathIdentity(
|
||||
resolveToRealPath(requestedPath),
|
||||
);
|
||||
|
||||
const needsExpansion =
|
||||
missingRead.length > 0 || missingWrite.length > 0 || missingNetwork;
|
||||
// Identity check is fast, subpath check is slower
|
||||
return approvedPaths.some((p) => {
|
||||
const approvedRealIdentity = getPathIdentity(
|
||||
resolveToRealPath(p),
|
||||
);
|
||||
return (
|
||||
requestedRealIdentity === approvedRealIdentity ||
|
||||
isSubpath(approvedRealIdentity, requestedRealIdentity)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (needsExpansion) {
|
||||
const details = await this.getConfirmationDetails(
|
||||
abortSignal,
|
||||
proactive,
|
||||
const missingRead = (proactive.fileSystem?.read || []).filter(
|
||||
(p) => !isApproved(p, approved.fileSystem?.read),
|
||||
);
|
||||
if (details && details.type === 'sandbox_expansion') {
|
||||
const originalOnConfirm = details.onConfirm;
|
||||
details.onConfirm = async (outcome: ToolConfirmationOutcome) => {
|
||||
await originalOnConfirm(outcome);
|
||||
if (outcome !== ToolConfirmationOutcome.Cancel) {
|
||||
this.proactivePermissionsConfirmed = proactive;
|
||||
}
|
||||
};
|
||||
const missingWrite = (proactive.fileSystem?.write || []).filter(
|
||||
(p) => !isApproved(p, approved.fileSystem?.write),
|
||||
);
|
||||
|
||||
const needsExpansion =
|
||||
missingRead.length > 0 ||
|
||||
missingWrite.length > 0 ||
|
||||
missingNetwork;
|
||||
|
||||
if (needsExpansion) {
|
||||
const details = await this.getConfirmationDetails(
|
||||
abortSignal,
|
||||
proactive,
|
||||
);
|
||||
if (details && details.type === 'sandbox_expansion') {
|
||||
const originalOnConfirm = details.onConfirm;
|
||||
details.onConfirm = async (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
) => {
|
||||
await originalOnConfirm(outcome);
|
||||
if (outcome !== ToolConfirmationOutcome.Cancel) {
|
||||
this.proactivePermissionsConfirmed = proactive;
|
||||
}
|
||||
};
|
||||
}
|
||||
return details;
|
||||
}
|
||||
return details;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -755,20 +785,22 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
);
|
||||
|
||||
// Proactive permission suggestions for Node ecosystem tools
|
||||
const proactive =
|
||||
await getProactiveToolSuggestions(rootCommandDisplay);
|
||||
if (proactive) {
|
||||
if (proactive.network) {
|
||||
sandboxDenial.network = true;
|
||||
}
|
||||
if (proactive.fileSystem?.read) {
|
||||
for (const p of proactive.fileSystem.read) {
|
||||
readPaths.add(p);
|
||||
if (this.context.config.getSandboxEnabled()) {
|
||||
const proactive =
|
||||
await getProactiveToolSuggestions(rootCommandDisplay);
|
||||
if (proactive) {
|
||||
if (proactive.network) {
|
||||
sandboxDenial.network = true;
|
||||
}
|
||||
}
|
||||
if (proactive.fileSystem?.write) {
|
||||
for (const p of proactive.fileSystem.write) {
|
||||
writePaths.add(p);
|
||||
if (proactive.fileSystem?.read) {
|
||||
for (const p of proactive.fileSystem.read) {
|
||||
readPaths.add(p);
|
||||
}
|
||||
}
|
||||
if (proactive.fileSystem?.write) {
|
||||
for (const p of proactive.fileSystem.write) {
|
||||
writePaths.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
beforeAll,
|
||||
afterEach,
|
||||
} from 'vitest';
|
||||
import os from 'node:os';
|
||||
import type _fs from 'node:fs';
|
||||
import { ShellTool } from './shell.js';
|
||||
import { type Config } from '../config/config.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import * as proactivePermissions from '../sandbox/utils/proactivePermissions.js';
|
||||
|
||||
import { initializeShellParsers } from '../utils/shell-utils.js';
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...original,
|
||||
default: {
|
||||
...original,
|
||||
realpathSync: vi.fn((p) => p),
|
||||
},
|
||||
realpathSync: vi.fn((p) => p),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../sandbox/utils/proactivePermissions.js', () => ({
|
||||
getProactiveToolSuggestions: vi.fn(),
|
||||
isNetworkReliantCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockPlatform = (platform: string) => {
|
||||
vi.stubGlobal(
|
||||
'process',
|
||||
Object.create(process, {
|
||||
platform: {
|
||||
get: () => platform,
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.spyOn(os, 'platform').mockReturnValue(platform as NodeJS.Platform);
|
||||
};
|
||||
|
||||
describe('ShellTool Proactive Expansion', () => {
|
||||
let mockConfig: Config;
|
||||
let shellTool: ShellTool;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initializeShellParsers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPlatform('darwin');
|
||||
|
||||
mockConfig = {
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||||
getTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||||
getApprovalMode: vi.fn().mockReturnValue('strict'),
|
||||
sandboxPolicyManager: {
|
||||
getCommandPermissions: vi.fn().mockReturnValue({
|
||||
fileSystem: { read: [], write: [] },
|
||||
network: false,
|
||||
}),
|
||||
getModeConfig: vi.fn().mockReturnValue({ readonly: false }),
|
||||
},
|
||||
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getShellToolInactivityTimeout: vi.fn().mockReturnValue(1000),
|
||||
} as unknown as Config;
|
||||
|
||||
const bus = createMockMessageBus();
|
||||
shellTool = new ShellTool(mockConfig, bus);
|
||||
});
|
||||
|
||||
it('should NOT call getProactiveToolSuggestions when sandboxing is disabled', async () => {
|
||||
const invocation = shellTool.build({ command: 'npm install' });
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
|
||||
expect(
|
||||
proactivePermissions.getProactiveToolSuggestions,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call getProactiveToolSuggestions when sandboxing is enabled', async () => {
|
||||
vi.mocked(mockConfig.getSandboxEnabled).mockReturnValue(true);
|
||||
vi.mocked(
|
||||
proactivePermissions.getProactiveToolSuggestions,
|
||||
).mockResolvedValue({
|
||||
network: true,
|
||||
});
|
||||
vi.mocked(proactivePermissions.isNetworkReliantCommand).mockReturnValue(
|
||||
true,
|
||||
);
|
||||
|
||||
const invocation = shellTool.build({ command: 'npm install' });
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
|
||||
expect(
|
||||
proactivePermissions.getProactiveToolSuggestions,
|
||||
).toHaveBeenCalledWith('npm');
|
||||
});
|
||||
|
||||
it('should normalize command names (lowercase and strip .exe) when sandboxing is enabled', async () => {
|
||||
vi.mocked(mockConfig.getSandboxEnabled).mockReturnValue(true);
|
||||
vi.mocked(
|
||||
proactivePermissions.getProactiveToolSuggestions,
|
||||
).mockResolvedValue({
|
||||
network: true,
|
||||
});
|
||||
vi.mocked(proactivePermissions.isNetworkReliantCommand).mockReturnValue(
|
||||
true,
|
||||
);
|
||||
|
||||
const invocation = shellTool.build({ command: 'NPM.EXE install' });
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
|
||||
expect(
|
||||
proactivePermissions.getProactiveToolSuggestions,
|
||||
).toHaveBeenCalledWith('npm');
|
||||
});
|
||||
|
||||
it('should NOT request expansion if paths are already approved (case-insensitive subpath)', async () => {
|
||||
// This test assumes Darwin or Windows for case-insensitivity
|
||||
vi.mocked(mockConfig.getSandboxEnabled).mockReturnValue(true);
|
||||
vi.mocked(
|
||||
proactivePermissions.getProactiveToolSuggestions,
|
||||
).mockResolvedValue({
|
||||
fileSystem: { read: ['/project/src'], write: [] },
|
||||
});
|
||||
vi.mocked(proactivePermissions.isNetworkReliantCommand).mockReturnValue(
|
||||
true,
|
||||
);
|
||||
|
||||
// Current approval is for the parent dir, with different casing
|
||||
vi.mocked(
|
||||
mockConfig.sandboxPolicyManager.getCommandPermissions,
|
||||
).mockReturnValue({
|
||||
fileSystem: { read: ['/PROJECT'], write: [] },
|
||||
network: false,
|
||||
});
|
||||
|
||||
const invocation = shellTool.build({ command: 'npm install' });
|
||||
const result = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
// If it's correctly approved, result should be false (no expansion needed)
|
||||
// or a normal 'exec' confirmation, but NOT 'sandbox_expansion'.
|
||||
if (result) {
|
||||
expect(result.type).not.toBe('sandbox_expansion');
|
||||
} else {
|
||||
expect(result).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
shortenPath,
|
||||
normalizePath,
|
||||
resolveToRealPath,
|
||||
makeRelative,
|
||||
} from './paths.js';
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
@@ -215,7 +216,7 @@ describe('isSubpath', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSubpath on Windows', () => {
|
||||
describe.skipIf(process.platform !== 'win32')('isSubpath on Windows', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
beforeEach(() => mockPlatform('win32'));
|
||||
@@ -268,6 +269,20 @@ describe('isSubpath on Windows', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform !== 'darwin')('isSubpath on Darwin', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
beforeEach(() => mockPlatform('darwin'));
|
||||
|
||||
it('should be case-insensitive for path components on Darwin', () => {
|
||||
expect(isSubpath('/PROJECT', '/project/src')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for a direct subpath on Darwin', () => {
|
||||
expect(isSubpath('/Users/Test', '/Users/Test/file.txt')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortenPath', () => {
|
||||
describe.skipIf(process.platform === 'win32')('on POSIX', () => {
|
||||
it('should not shorten a path that is shorter than maxLen', () => {
|
||||
@@ -586,6 +601,54 @@ describe('resolveToRealPath', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeRelative', () => {
|
||||
describe.skipIf(process.platform === 'win32')('on POSIX', () => {
|
||||
it('should return relative path if targetPath is already relative', () => {
|
||||
expect(makeRelative('foo/bar', '/root')).toBe('foo/bar');
|
||||
});
|
||||
|
||||
it('should return relative path from root to target', () => {
|
||||
const root = '/Users/test/project';
|
||||
const target = '/Users/test/project/src/file.ts';
|
||||
expect(makeRelative(target, root)).toBe('src/file.ts');
|
||||
});
|
||||
|
||||
it('should return "." if target and root are the same', () => {
|
||||
const root = '/Users/test/project';
|
||||
expect(makeRelative(root, root)).toBe('.');
|
||||
});
|
||||
|
||||
it('should handle parent directories with ..', () => {
|
||||
const root = '/Users/test/project/src';
|
||||
const target = '/Users/test/project/docs/readme.md';
|
||||
expect(makeRelative(target, root)).toBe('../docs/readme.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform !== 'win32')('on Windows', () => {
|
||||
it('should return relative path if targetPath is already relative', () => {
|
||||
expect(makeRelative('foo/bar', 'C:\\root')).toBe('foo/bar');
|
||||
});
|
||||
|
||||
it('should return relative path from root to target', () => {
|
||||
const root = 'C:\\Users\\test\\project';
|
||||
const target = 'C:\\Users\\test\\project\\src\\file.ts';
|
||||
expect(makeRelative(target, root)).toBe('src\\file.ts');
|
||||
});
|
||||
|
||||
it('should return "." if target and root are the same', () => {
|
||||
const root = 'C:\\Users\\test\\project';
|
||||
expect(makeRelative(root, root)).toBe('.');
|
||||
});
|
||||
|
||||
it('should handle parent directories with ..', () => {
|
||||
const root = 'C:\\Users\\test\\project\\src';
|
||||
const target = 'C:\\Users\\test\\project\\docs\\readme.md';
|
||||
expect(makeRelative(target, root)).toBe('..\\docs\\readme.md');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePath', () => {
|
||||
it('should resolve a relative path to an absolute path', () => {
|
||||
const result = normalizePath('some/relative/path');
|
||||
@@ -615,7 +678,19 @@ describe('normalizePath', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('on POSIX', () => {
|
||||
describe.skipIf(process.platform !== 'darwin')('on Darwin', () => {
|
||||
beforeEach(() => mockPlatform('darwin'));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('should lowercase the entire path', () => {
|
||||
const result = normalizePath('/Users/TEST');
|
||||
expect(result).toBe('/users/test');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(
|
||||
process.platform === 'win32' || process.platform === 'darwin',
|
||||
)('on Linux', () => {
|
||||
it('should preserve case', () => {
|
||||
const result = normalizePath('/usr/Local/Bin');
|
||||
expect(result).toContain('Local');
|
||||
|
||||
@@ -325,9 +325,14 @@ export function getProjectHash(projectRoot: string): string {
|
||||
* - On Windows, converts to lowercase for case-insensitivity.
|
||||
*/
|
||||
export function normalizePath(p: string): string {
|
||||
const resolved = path.resolve(p);
|
||||
const platform = process.platform;
|
||||
const isWindows = platform === 'win32';
|
||||
const pathModule = isWindows ? path.win32 : path;
|
||||
|
||||
const resolved = pathModule.resolve(p);
|
||||
const normalized = resolved.replace(/\\/g, '/');
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
const isCaseInsensitive = isWindows || platform === 'darwin';
|
||||
return isCaseInsensitive ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,11 +342,25 @@ export function normalizePath(p: string): string {
|
||||
* @returns True if childPath is a subpath of parentPath, false otherwise.
|
||||
*/
|
||||
export function isSubpath(parentPath: string, childPath: string): boolean {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const platform = process.platform;
|
||||
const isWindows = platform === 'win32';
|
||||
const isDarwin = platform === 'darwin';
|
||||
const pathModule = isWindows ? path.win32 : path;
|
||||
|
||||
// On Windows, path.relative is case-insensitive. On POSIX, it's case-sensitive.
|
||||
const relative = pathModule.relative(parentPath, childPath);
|
||||
// Resolve both paths to absolute to ensure consistent comparison,
|
||||
// especially when mixing relative and absolute paths or when casing differs.
|
||||
let p = pathModule.resolve(parentPath);
|
||||
let c = pathModule.resolve(childPath);
|
||||
|
||||
// On Windows, path.relative is case-insensitive.
|
||||
// On POSIX (including Darwin), path.relative is case-sensitive.
|
||||
// We want it to be case-insensitive on Darwin to match user expectation and sandbox policy.
|
||||
if (isDarwin) {
|
||||
p = p.toLowerCase();
|
||||
c = c.toLowerCase();
|
||||
}
|
||||
|
||||
const relative = pathModule.relative(p, c);
|
||||
|
||||
return (
|
||||
!relative.startsWith(`..${pathModule.sep}`) &&
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
parseCommandDetails,
|
||||
splitCommands,
|
||||
stripShellWrapper,
|
||||
normalizeCommand,
|
||||
hasRedirection,
|
||||
resolveExecutable,
|
||||
} from './shell-utils.js';
|
||||
@@ -115,6 +116,23 @@ const mockPowerShellResult = (
|
||||
});
|
||||
};
|
||||
|
||||
describe('normalizeCommand', () => {
|
||||
it('should lowercase the command', () => {
|
||||
expect(normalizeCommand('NPM')).toBe('npm');
|
||||
});
|
||||
|
||||
it('should remove .exe extension', () => {
|
||||
expect(normalizeCommand('node.exe')).toBe('node');
|
||||
});
|
||||
|
||||
it('should handle absolute paths', () => {
|
||||
expect(normalizeCommand('/usr/bin/npm')).toBe('npm');
|
||||
expect(normalizeCommand('C:\\Program Files\\nodejs\\node.exe')).toBe(
|
||||
'node',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCommandRoots', () => {
|
||||
it('should return a single command', () => {
|
||||
expect(getCommandRoots('ls -l')).toEqual(['ls']);
|
||||
|
||||
@@ -310,6 +310,20 @@ function normalizeCommandName(raw: string): string {
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a command name for sandbox policy lookups.
|
||||
* Converts to lowercase and removes the .exe extension for cross-platform consistency.
|
||||
*
|
||||
* @param commandName - The command name to normalize.
|
||||
* @returns The normalized command name.
|
||||
*/
|
||||
export function normalizeCommand(commandName: string): string {
|
||||
// Split by both separators and get the last non-empty part
|
||||
const parts = commandName.split(/[\\/]/).filter(Boolean);
|
||||
const base = parts.length > 0 ? parts[parts.length - 1] : '';
|
||||
return base.toLowerCase().replace(/\.exe$/, '');
|
||||
}
|
||||
|
||||
function extractNameFromNode(node: Node): string | null {
|
||||
switch (node.type) {
|
||||
case 'command': {
|
||||
|
||||
Reference in New Issue
Block a user