Merge branch 'main' into memory_usage3

This commit is contained in:
Spencer
2026-04-14 16:31:05 -04:00
committed by GitHub
24 changed files with 1344 additions and 594 deletions
@@ -0,0 +1,132 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: 'Agent Session Drift Check'
on:
pull_request:
branches:
- 'main'
- 'release/**'
paths:
- 'packages/cli/src/nonInteractiveCli.ts'
- 'packages/cli/src/nonInteractiveCliAgentSession.ts'
concurrency:
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
cancel-in-progress: true
jobs:
check-drift:
name: 'Check Agent Session Drift'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
permissions:
contents: 'read'
pull-requests: 'write'
steps:
- name: 'Detect drift and comment'
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v8
with:
script: |-
// === Pair configuration — append here to cover more pairs ===
const PAIRS = [
{
legacy: 'packages/cli/src/nonInteractiveCli.ts',
session: 'packages/cli/src/nonInteractiveCliAgentSession.ts',
label: 'non-interactive CLI',
},
// Future pairs can be added here. Remember to also add both
// paths to the `paths:` filter at the top of this workflow.
// Example:
// {
// legacy: 'packages/core/src/agents/local-invocation.ts',
// session: 'packages/core/src/agents/local-session-invocation.ts',
// label: 'local subagent invocation',
// },
];
// ============================================================
const prNumber = context.payload.pull_request.number;
const { owner, repo } = context.repo;
// Use the API to list changed files — no checkout/git diff needed.
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
const changed = new Set(files.map((f) => f.filename));
const warnings = [];
for (const { legacy, session, label } of PAIRS) {
const legacyChanged = changed.has(legacy);
const sessionChanged = changed.has(session);
if (legacyChanged && !sessionChanged) {
warnings.push(
`**${label}**: \`${legacy}\` was modified but \`${session}\` was not.`,
);
} else if (!legacyChanged && sessionChanged) {
warnings.push(
`**${label}**: \`${session}\` was modified but \`${legacy}\` was not.`,
);
}
}
const MARKER = '<!-- agent-session-drift-check -->';
// Look up our existing drift comment (for upsert/cleanup).
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
const existing = comments.find(
(c) => c.user?.type === 'Bot' && c.body?.includes(MARKER),
);
if (warnings.length === 0) {
core.info('No drift detected.');
// If drift was previously flagged and is now resolved, remove the comment.
if (existing) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: existing.id,
});
core.info(`Deleted stale drift comment ${existing.id}.`);
}
return;
}
const body = [
MARKER,
'### ⚠️ Invocation Drift Warning',
'',
'The following file pairs should generally be kept in sync during the AgentSession migration:',
'',
...warnings.map((w) => `- ${w}`),
'',
'If this is intentional (e.g., a bug fix specific to one implementation), you can ignore this comment.',
'',
'_This check will be removed once the legacy implementations are deleted._',
].join('\n');
if (existing) {
core.info(`Updating existing drift comment ${existing.id}.`);
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
core.info('Creating new drift comment.');
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
}
+16 -11
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { render } from '../../test-utils/render.js'; import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js'; import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { ApiAuthDialog } from './ApiAuthDialog.js'; import { ApiAuthDialog } from './ApiAuthDialog.js';
@@ -40,11 +40,16 @@ vi.mock('../components/shared/text-buffer.js', async (importOriginal) => {
}; };
}); });
vi.mock('../contexts/UIStateContext.js', () => ({ vi.mock('../contexts/UIStateContext.js', async (importOriginal) => {
useUIState: vi.fn(() => ({ const actual =
terminalWidth: 80, await importOriginal<typeof import('../contexts/UIStateContext.js')>();
})), return {
})); ...actual,
useUIState: vi.fn(() => ({
terminalWidth: 80,
})),
};
});
const mockedUseKeypress = useKeypress as Mock; const mockedUseKeypress = useKeypress as Mock;
const mockedUseTextBuffer = useTextBuffer as Mock; const mockedUseTextBuffer = useTextBuffer as Mock;
@@ -73,7 +78,7 @@ describe('ApiAuthDialog', () => {
}); });
it('renders correctly', async () => { it('renders correctly', async () => {
const { lastFrame, unmount } = await render( const { lastFrame, unmount } = await renderWithProviders(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />, <ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
@@ -81,7 +86,7 @@ describe('ApiAuthDialog', () => {
}); });
it('renders with a defaultValue', async () => { it('renders with a defaultValue', async () => {
const { unmount } = await render( const { unmount } = await renderWithProviders(
<ApiAuthDialog <ApiAuthDialog
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={onCancel} onCancel={onCancel}
@@ -111,7 +116,7 @@ describe('ApiAuthDialog', () => {
'calls $expectedCall.name when $keyName is pressed', 'calls $expectedCall.name when $keyName is pressed',
async ({ keyName, sequence, expectedCall, args }) => { async ({ keyName, sequence, expectedCall, args }) => {
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
const { unmount } = await render( const { unmount } = await renderWithProviders(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />, <ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
); );
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler) // calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
@@ -133,7 +138,7 @@ describe('ApiAuthDialog', () => {
); );
it('displays an error message', async () => { it('displays an error message', async () => {
const { lastFrame, unmount } = await render( const { lastFrame, unmount } = await renderWithProviders(
<ApiAuthDialog <ApiAuthDialog
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={onCancel} onCancel={onCancel}
@@ -146,7 +151,7 @@ describe('ApiAuthDialog', () => {
}); });
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => { it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
const { unmount } = await render( const { unmount } = await renderWithProviders(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />, <ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
); );
// Call 0 is ApiAuthDialog (isActive: true) // Call 0 is ApiAuthDialog (isActive: true)
@@ -13,7 +13,8 @@ import {
useReducer, useReducer,
useContext, useContext,
} from 'react'; } from 'react';
import { Box, Text } from 'ink'; import { Box, Text, type DOMElement } from 'ink';
import { useMouseClick } from '../hooks/useMouseClick.js';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { checkExhaustive, type Question } from '@google/gemini-cli-core'; import { checkExhaustive, type Question } from '@google/gemini-cli-core';
import { BaseSelectionList } from './shared/BaseSelectionList.js'; import { BaseSelectionList } from './shared/BaseSelectionList.js';
@@ -85,6 +86,24 @@ function autoBoldIfPlain(text: string): string {
return text; return text;
} }
const ClickableCheckbox: React.FC<{
isChecked: boolean;
onClick: () => void;
}> = ({ isChecked, onClick }) => {
const ref = useRef<DOMElement>(null);
useMouseClick(ref, () => {
onClick();
});
return (
<Box ref={ref}>
<Text color={isChecked ? theme.status.success : theme.text.secondary}>
[{isChecked ? 'x' : ' '}]
</Text>
</Box>
);
};
interface AskUserDialogState { interface AskUserDialogState {
answers: { [key: string]: string }; answers: { [key: string]: string };
isEditingCustomOption: boolean; isEditingCustomOption: boolean;
@@ -919,13 +938,14 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
return ( return (
<Box flexDirection="row"> <Box flexDirection="row">
{showCheck && ( {showCheck && (
<Text <ClickableCheckbox
color={ isChecked={isChecked}
isChecked ? theme.status.success : theme.text.secondary onClick={() => {
} if (!context.isSelected) {
> handleSelect(optionItem);
[{isChecked ? 'x' : ' '}] }
</Text> }}
/>
)} )}
<Text color={theme.text.primary}> </Text> <Text color={theme.text.primary}> </Text>
<TextInput <TextInput
@@ -966,13 +986,14 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
<Box flexDirection="column"> <Box flexDirection="column">
<Box flexDirection="row"> <Box flexDirection="row">
{showCheck && ( {showCheck && (
<Text <ClickableCheckbox
color={ isChecked={isChecked}
isChecked ? theme.status.success : theme.text.secondary onClick={() => {
} if (!context.isSelected) {
> handleSelect(optionItem);
[{isChecked ? 'x' : ' '}] }
</Text> }}
/>
)} )}
<Text color={labelColor} bold={optionItem.type === 'done'}> <Text color={labelColor} bold={optionItem.type === 'done'}>
{' '} {' '}
@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../../test-utils/render.js'; import { renderWithProviders } from '../../../test-utils/render.js';
import { import {
BaseSelectionList, BaseSelectionList,
@@ -14,8 +15,10 @@ import {
import { useSelectionList } from '../../hooks/useSelectionList.js'; import { useSelectionList } from '../../hooks/useSelectionList.js';
import { Text } from 'ink'; import { Text } from 'ink';
import type { theme } from '../../semantic-colors.js'; import type { theme } from '../../semantic-colors.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
vi.mock('../../hooks/useSelectionList.js'); vi.mock('../../hooks/useSelectionList.js');
vi.mock('../../hooks/useMouseClick.js');
const mockTheme = { const mockTheme = {
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' }, text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
@@ -35,6 +38,7 @@ describe('BaseSelectionList', () => {
const mockOnSelect = vi.fn(); const mockOnSelect = vi.fn();
const mockOnHighlight = vi.fn(); const mockOnHighlight = vi.fn();
const mockRenderItem = vi.fn(); const mockRenderItem = vi.fn();
const mockSetActiveIndex = vi.fn();
const items = [ const items = [
{ value: 'A', label: 'Item A', key: 'A' }, { value: 'A', label: 'Item A', key: 'A' },
@@ -54,7 +58,7 @@ describe('BaseSelectionList', () => {
) => { ) => {
vi.mocked(useSelectionList).mockReturnValue({ vi.mocked(useSelectionList).mockReturnValue({
activeIndex, activeIndex,
setActiveIndex: vi.fn(), setActiveIndex: mockSetActiveIndex,
}); });
mockRenderItem.mockImplementation( mockRenderItem.mockImplementation(
@@ -484,6 +488,79 @@ describe('BaseSelectionList', () => {
}); });
}); });
describe('Mouse Interaction', () => {
it('should register mouse click handler for each item', async () => {
const { unmount } = await renderComponent();
// items are A, B (disabled), C
expect(useMouseClick).toHaveBeenCalledTimes(3);
unmount();
});
it('should update activeIndex on first click and call onSelect on second click', async () => {
const { unmount, waitUntilReady } = await renderComponent();
await waitUntilReady();
// items[0] is 'A' (enabled)
// items[1] is 'B' (disabled)
// items[2] is 'C' (enabled)
// Get the mouse click handler for the third item (index 2)
const mouseClickHandler = (useMouseClick as Mock).mock.calls[2][1];
// First click on item C
act(() => {
mouseClickHandler();
});
expect(mockSetActiveIndex).toHaveBeenCalledWith(2);
expect(mockOnSelect).not.toHaveBeenCalled();
// Now simulate being on item C (isSelected = true)
// Rerender or update mocks for the next check
await renderComponent({}, 2);
// Get the updated mouse click handler for item C
// useMouseClick is called 3 more times on rerender
const updatedMouseClickHandler = (useMouseClick as Mock).mock.calls[5][1];
// Second click on item C
act(() => {
updatedMouseClickHandler();
});
expect(mockOnSelect).toHaveBeenCalledWith('C');
unmount();
});
it('should not call onSelect when a disabled item is clicked', async () => {
const { unmount, waitUntilReady } = await renderComponent();
await waitUntilReady();
// items[1] is 'B' (disabled)
const mouseClickHandler = (useMouseClick as Mock).mock.calls[1][1];
act(() => {
mouseClickHandler();
});
expect(mockSetActiveIndex).not.toHaveBeenCalled();
expect(mockOnSelect).not.toHaveBeenCalled();
unmount();
});
it('should pass isActive: isFocused to useMouseClick', async () => {
const { unmount } = await renderComponent({ isFocused: false });
expect(useMouseClick).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Function),
{ isActive: false },
);
unmount();
});
});
describe('Scroll Arrows (showScrollArrows)', () => { describe('Scroll Arrows (showScrollArrows)', () => {
const longList = Array.from({ length: 10 }, (_, i) => ({ const longList = Array.from({ length: 10 }, (_, i) => ({
value: `Item ${i + 1}`, value: `Item ${i + 1}`,
@@ -5,13 +5,14 @@
*/ */
import type React from 'react'; import type React from 'react';
import { useState } from 'react'; import { useState, useRef } from 'react';
import { Text, Box } from 'ink'; import { Text, Box, type DOMElement } from 'ink';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../semantic-colors.js';
import { import {
useSelectionList, useSelectionList,
type SelectionListItem, type SelectionListItem,
} from '../../hooks/useSelectionList.js'; } from '../../hooks/useSelectionList.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
export interface RenderItemContext { export interface RenderItemContext {
isSelected: boolean; isSelected: boolean;
@@ -38,6 +39,119 @@ export interface BaseSelectionListProps<
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode; renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
} }
interface SelectionListItemRowProps<
T,
TItem extends SelectionListItem<T> = SelectionListItem<T>,
> {
item: TItem;
itemIndex: number;
isSelected: boolean;
isFocused: boolean;
showNumbers: boolean;
selectedIndicator: string;
numberColumnWidth: number;
onSelect: (value: T) => void;
setActiveIndex: (index: number) => void;
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
}
function SelectionListItemRow<
T,
TItem extends SelectionListItem<T> = SelectionListItem<T>,
>({
item,
itemIndex,
isSelected,
isFocused,
showNumbers,
selectedIndicator,
numberColumnWidth,
onSelect,
setActiveIndex,
renderItem,
}: SelectionListItemRowProps<T, TItem>) {
const containerRef = useRef<DOMElement>(null);
useMouseClick(
containerRef,
() => {
if (!item.disabled) {
if (isSelected) {
// Second click on the same item triggers submission
onSelect(item.value);
} else {
// First click highlights the item
setActiveIndex(itemIndex);
}
}
},
{ isActive: isFocused },
);
let titleColor = theme.text.primary;
let numberColor = theme.text.primary;
if (isSelected) {
titleColor = theme.ui.focus;
numberColor = theme.ui.focus;
} else if (item.disabled) {
titleColor = theme.text.secondary;
numberColor = theme.text.secondary;
}
if (!isFocused && !item.disabled) {
numberColor = theme.text.secondary;
}
if (!showNumbers) {
numberColor = theme.text.secondary;
}
const itemNumberText = `${String(itemIndex + 1).padStart(
numberColumnWidth,
)}.`;
return (
<Box
ref={containerRef}
key={item.key}
alignItems="flex-start"
backgroundColor={isSelected ? theme.background.focus : undefined}
>
{/* Radio button indicator */}
<Box minWidth={2} flexShrink={0}>
<Text
color={isSelected ? theme.ui.focus : theme.text.primary}
aria-hidden
>
{isSelected ? selectedIndicator : ' '}
</Text>
</Box>
{/* Item number */}
{showNumbers && !item.hideNumber && (
<Box
marginRight={1}
flexShrink={0}
minWidth={itemNumberText.length}
aria-state={{ checked: isSelected }}
>
<Text color={numberColor}>{itemNumberText}</Text>
</Box>
)}
{/* Custom content via render prop */}
<Box flexGrow={1}>
{renderItem(item, {
isSelected,
titleColor,
numberColor,
})}
</Box>
</Box>
);
}
/** /**
* Base component for selection lists that provides common UI structure * Base component for selection lists that provides common UI structure
* and keyboard navigation logic via the useSelectionList hook. * and keyboard navigation logic via the useSelectionList hook.
@@ -70,7 +184,7 @@ export function BaseSelectionList<
selectedIndicator = '●', selectedIndicator = '●',
renderItem, renderItem,
}: BaseSelectionListProps<T, TItem>): React.JSX.Element { }: BaseSelectionListProps<T, TItem>): React.JSX.Element {
const { activeIndex } = useSelectionList({ const { activeIndex, setActiveIndex } = useSelectionList({
items, items,
initialIndex, initialIndex,
onSelect, onSelect,
@@ -107,10 +221,12 @@ export function BaseSelectionList<
); );
const numberColumnWidth = String(items.length).length; const numberColumnWidth = String(items.length).length;
const showArrows = showScrollArrows && items.length > maxItemsToShow;
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{/* Use conditional coloring instead of conditional rendering */} {/* Use conditional coloring instead of conditional rendering */}
{showScrollArrows && items.length > maxItemsToShow && ( {showArrows && (
<Text <Text
color={ color={
effectiveScrollOffset > 0 effectiveScrollOffset > 0
@@ -126,71 +242,24 @@ export function BaseSelectionList<
const itemIndex = effectiveScrollOffset + index; const itemIndex = effectiveScrollOffset + index;
const isSelected = activeIndex === itemIndex; const isSelected = activeIndex === itemIndex;
// Determine colors based on selection and disabled state
let titleColor = theme.text.primary;
let numberColor = theme.text.primary;
if (isSelected) {
titleColor = theme.ui.focus;
numberColor = theme.ui.focus;
} else if (item.disabled) {
titleColor = theme.text.secondary;
numberColor = theme.text.secondary;
}
if (!isFocused && !item.disabled) {
numberColor = theme.text.secondary;
}
if (!showNumbers) {
numberColor = theme.text.secondary;
}
const itemNumberText = `${String(itemIndex + 1).padStart(
numberColumnWidth,
)}.`;
return ( return (
<Box <SelectionListItemRow
key={item.key} key={item.key}
alignItems="flex-start" item={item}
backgroundColor={isSelected ? theme.background.focus : undefined} itemIndex={itemIndex}
> isSelected={isSelected}
{/* Radio button indicator */} isFocused={isFocused}
<Box minWidth={2} flexShrink={0}> showNumbers={showNumbers}
<Text selectedIndicator={selectedIndicator}
color={isSelected ? theme.ui.focus : theme.text.primary} numberColumnWidth={numberColumnWidth}
aria-hidden onSelect={onSelect}
> setActiveIndex={setActiveIndex}
{isSelected ? selectedIndicator : ' '} renderItem={renderItem}
</Text> />
</Box>
{/* Item number */}
{showNumbers && !item.hideNumber && (
<Box
marginRight={1}
flexShrink={0}
minWidth={itemNumberText.length}
aria-state={{ checked: isSelected }}
>
<Text color={numberColor}>{itemNumberText}</Text>
</Box>
)}
{/* Custom content via render prop */}
<Box flexGrow={1}>
{renderItem(item, {
isSelected,
titleColor,
numberColor,
})}
</Box>
</Box>
); );
})} })}
{showScrollArrows && items.length > maxItemsToShow && ( {showArrows && (
<Text <Text
color={ color={
effectiveScrollOffset + maxItemsToShow < items.length effectiveScrollOffset + maxItemsToShow < items.length
@@ -11,12 +11,17 @@ import { act } from 'react';
import { TextInput } from './TextInput.js'; import { TextInput } from './TextInput.js';
import { useKeypress } from '../../hooks/useKeypress.js'; import { useKeypress } from '../../hooks/useKeypress.js';
import { useTextBuffer, type TextBuffer } from './text-buffer.js'; import { useTextBuffer, type TextBuffer } from './text-buffer.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
// Mocks // Mocks
vi.mock('../../hooks/useKeypress.js', () => ({ vi.mock('../../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(), useKeypress: vi.fn(),
})); }));
vi.mock('../../hooks/useMouseClick.js', () => ({
useMouseClick: vi.fn(),
}));
vi.mock('./text-buffer.js', async (importOriginal) => { vi.mock('./text-buffer.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./text-buffer.js')>(); const actual = await importOriginal<typeof import('./text-buffer.js')>();
const mockTextBuffer = { const mockTextBuffer = {
@@ -69,6 +74,7 @@ vi.mock('./text-buffer.js', async (importOriginal) => {
const mockedUseKeypress = useKeypress as Mock; const mockedUseKeypress = useKeypress as Mock;
const mockedUseTextBuffer = useTextBuffer as Mock; const mockedUseTextBuffer = useTextBuffer as Mock;
const mockedUseMouseClick = useMouseClick as Mock;
describe('TextInput', () => { describe('TextInput', () => {
const onCancel = vi.fn(); const onCancel = vi.fn();
@@ -84,6 +90,7 @@ describe('TextInput', () => {
cursor: [0, 0], cursor: [0, 0],
visualCursor: [0, 0], visualCursor: [0, 0],
viewportVisualLines: [''], viewportVisualLines: [''],
visualScrollRow: 0,
pastedContent: {} as Record<string, string>, pastedContent: {} as Record<string, string>,
handleInput: vi.fn((key) => { handleInput: vi.fn((key) => {
if (key.sequence) { if (key.sequence) {
@@ -408,4 +415,36 @@ describe('TextInput', () => {
expect(lastFrame()).toContain('line2'); expect(lastFrame()).toContain('line2');
unmount(); unmount();
}); });
it('registers mouse click handler for free-form text input', async () => {
const { unmount } = await render(
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
);
expect(mockedUseMouseClick).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Function),
expect.objectContaining({ isActive: true, name: 'left-press' }),
);
unmount();
});
it('registers mouse click handler for placeholder view', async () => {
mockBuffer.text = '';
const { unmount } = await render(
<TextInput
buffer={mockBuffer}
placeholder="test"
onSubmit={onSubmit}
onCancel={onCancel}
/>,
);
expect(mockedUseMouseClick).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Function),
expect.objectContaining({ isActive: true, name: 'left-press' }),
);
unmount();
});
}); });
@@ -5,8 +5,8 @@
*/ */
import type React from 'react'; import type React from 'react';
import { useCallback } from 'react'; import { useCallback, useRef } from 'react';
import { Text, Box } from 'ink'; import { Text, Box, type DOMElement } from 'ink';
import { useKeypress, type Key } from '../../hooks/useKeypress.js'; import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import chalk from 'chalk'; import chalk from 'chalk';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../semantic-colors.js';
@@ -14,6 +14,7 @@ import { expandPastePlaceholders, type TextBuffer } from './text-buffer.js';
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js'; import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
import { Command } from '../../key/keyMatchers.js'; import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
export interface TextInputProps { export interface TextInputProps {
buffer: TextBuffer; buffer: TextBuffer;
@@ -31,6 +32,8 @@ export function TextInput({
focus = true, focus = true,
}: TextInputProps): React.JSX.Element { }: TextInputProps): React.JSX.Element {
const keyMatchers = useKeyMatchers(); const keyMatchers = useKeyMatchers();
const containerRef = useRef<DOMElement>(null);
const { const {
text, text,
handleInput, handleInput,
@@ -40,6 +43,17 @@ export function TextInput({
} = buffer; } = buffer;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = visualCursor; const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = visualCursor;
useMouseClick(
containerRef,
(_event, relativeX, relativeY) => {
if (focus) {
const visRowAbsolute = visualScrollRow + relativeY;
buffer.moveToVisualPosition(visRowAbsolute, relativeX);
}
},
{ isActive: focus, name: 'left-press' },
);
const handleKeyPress = useCallback( const handleKeyPress = useCallback(
(key: Key) => { (key: Key) => {
if (key.name === 'escape' && onCancel) { if (key.name === 'escape' && onCancel) {
@@ -64,7 +78,7 @@ export function TextInput({
if (showPlaceholder) { if (showPlaceholder) {
return ( return (
<Box> <Box ref={containerRef}>
{focus ? ( {focus ? (
<Text terminalCursorFocus={focus} terminalCursorPosition={0}> <Text terminalCursorFocus={focus} terminalCursorPosition={0}>
{chalk.inverse(placeholder[0] || ' ')} {chalk.inverse(placeholder[0] || ' ')}
@@ -78,7 +92,7 @@ export function TextInput({
} }
return ( return (
<Box flexDirection="column"> <Box ref={containerRef} flexDirection="column">
{viewportVisualLines.map((lineText, idx) => { {viewportVisualLines.map((lineText, idx) => {
const currentVisualRow = visualScrollRow + idx; const currentVisualRow = visualScrollRow + idx;
const isCursorLine = const isCursorLine =
@@ -119,6 +119,7 @@ The following tools are available in Plan Mode:
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below. - **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames. 5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames.
6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use \`exit_plan_mode\` to request approval. 6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use \`exit_plan_mode\` to request approval.
7. **Presenting Plan:** When seeking informal agreement on a plan, or any time the user asks to see the plan, you MUST output the full content of the plan in the chat response. This overrides the "Minimal Output" guideline.
## Planning Workflow ## Planning Workflow
Plan Mode uses an adaptive planning workflow where the research depth, plan structure, and consultation level are proportional to the task's complexity. Plan Mode uses an adaptive planning workflow where the research depth, plan structure, and consultation level are proportional to the task's complexity.
@@ -139,6 +140,7 @@ Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to
- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps. - **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps.
- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**. - **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**.
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies. - **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
- **Alignment Check:** After drafting the plan, you MUST present it to the user in the chat (adhering to Rule 7 for presenting plans) to ensure alignment on the specific details. Ask for feedback or confirmation, and proceed to Step 4 (Review & Approval) once the user agrees with the detailed plan.
### 4. Review & Approval ### 4. Review & Approval
ONLY use the \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and formally request approval. ONLY use the \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and formally request approval.
@@ -297,6 +299,7 @@ The following tools are available in Plan Mode:
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below. - **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames. 5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames.
6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use \`exit_plan_mode\` to request approval. 6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use \`exit_plan_mode\` to request approval.
7. **Presenting Plan:** When seeking informal agreement on a plan, or any time the user asks to see the plan, you MUST output the full content of the plan in the chat response. This overrides the "Minimal Output" guideline.
## Planning Workflow ## Planning Workflow
Plan Mode uses an adaptive planning workflow where the research depth, plan structure, and consultation level are proportional to the task's complexity. Plan Mode uses an adaptive planning workflow where the research depth, plan structure, and consultation level are proportional to the task's complexity.
@@ -317,6 +320,7 @@ Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to
- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps. - **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps.
- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**. - **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**.
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies. - **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
- **Alignment Check:** After drafting the plan, you MUST present it to the user in the chat (adhering to Rule 7 for presenting plans) to ensure alignment on the specific details. Ask for feedback or confirmation, and proceed to Step 4 (Review & Approval) once the user agrees with the detailed plan.
### 4. Review & Approval ### 4. Review & Approval
ONLY use the \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and formally request approval. ONLY use the \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and formally request approval.
@@ -596,6 +600,7 @@ The following tools are available in Plan Mode:
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below. - **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames. 5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames.
6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use \`exit_plan_mode\` to request approval. 6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use \`exit_plan_mode\` to request approval.
7. **Presenting Plan:** When seeking informal agreement on a plan, or any time the user asks to see the plan, you MUST output the full content of the plan in the chat response. This overrides the "Minimal Output" guideline.
## Planning Workflow ## Planning Workflow
Plan Mode uses an adaptive planning workflow where the research depth, plan structure, and consultation level are proportional to the task's complexity. Plan Mode uses an adaptive planning workflow where the research depth, plan structure, and consultation level are proportional to the task's complexity.
@@ -616,6 +621,7 @@ Write the implementation plan to \`/tmp/project-temp/plans/\`. The plan's struct
- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps. - **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps.
- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**. - **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**.
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies. - **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
- **Alignment Check:** After drafting the plan, you MUST present it to the user in the chat (adhering to Rule 7 for presenting plans) to ensure alignment on the specific details. Ask for feedback or confirmation, and proceed to Step 4 (Review & Approval) once the user agrees with the detailed plan.
### 4. Review & Approval ### 4. Review & Approval
ONLY use the \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and formally request approval. ONLY use the \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and formally request approval.
+2 -1
View File
@@ -586,6 +586,7 @@ ${options.planModeToolsList}
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below. - **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames. 5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames.
6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} to request approval. 6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} to request approval.
7. **Presenting Plan:** When seeking informal agreement on a plan, or any time the user asks to see the plan, you MUST output the full content of the plan in the chat response. This overrides the "Minimal Output" guideline.
## Planning Workflow ## Planning Workflow
Plan Mode uses an adaptive planning workflow where the research depth, plan structure, and consultation level are proportional to the task's complexity. Plan Mode uses an adaptive planning workflow where the research depth, plan structure, and consultation level are proportional to the task's complexity.
@@ -605,7 +606,7 @@ The depth of your consultation should be proportional to the task's complexity.
Write the implementation plan to \`${options.plansDir}/\`. The plan's structure adapts to the task: Write the implementation plan to \`${options.plansDir}/\`. The plan's structure adapts to the task:
- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps. - **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps.
- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**. - **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**.
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies. - **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.${options.interactive ? '\n- **Alignment Check:** After drafting the plan, you MUST present it to the user in the chat (adhering to Rule 7 for presenting plans) to ensure alignment on the specific details. Ask for feedback or confirmation, and proceed to Step 4 (Review & Approval) once the user agrees with the detailed plan.' : ''}
### 4. Review & Approval ### 4. Review & Approval
ONLY use the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and ${options.interactive ? 'formally request approval.' : 'begin implementation.'} ONLY use the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and ${options.interactive ? 'formally request approval.' : 'begin implementation.'}
@@ -37,6 +37,7 @@ import {
createSandboxDenialCache, createSandboxDenialCache,
type SandboxDenialCache, type SandboxDenialCache,
} from '../utils/sandboxDenialUtils.js'; } from '../utils/sandboxDenialUtils.js';
import { isErrnoException } from '../utils/fsUtils.js';
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js'; import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
import { buildBwrapArgs } from './bwrapArgsBuilder.js'; import { buildBwrapArgs } from './bwrapArgsBuilder.js';
@@ -116,9 +117,12 @@ function touch(filePath: string, isDirectory: boolean) {
assertValidPathString(filePath); assertValidPathString(filePath);
try { try {
// If it exists (even as a broken symlink), do nothing // If it exists (even as a broken symlink), do nothing
if (fs.lstatSync(filePath)) return; fs.lstatSync(filePath);
} catch { return;
// Ignore ENOENT } catch (e: unknown) {
if (isErrnoException(e) && e.code !== 'ENOENT') {
throw e;
}
} }
if (isDirectory) { if (isDirectory) {
@@ -136,9 +140,22 @@ function touch(filePath: string, isDirectory: boolean) {
export class LinuxSandboxManager implements SandboxManager { export class LinuxSandboxManager implements SandboxManager {
private static maskFilePath: string | undefined; private static maskFilePath: string | undefined;
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache(); private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
private governanceFilesInitialized = false;
constructor(private readonly options: GlobalSandboxOptions) {} constructor(private readonly options: GlobalSandboxOptions) {}
private ensureGovernanceFilesExist(workspace: string): void {
if (this.governanceFilesInitialized) return;
// These must exist on the host before running the sandbox to ensure they are protected.
for (const file of GOVERNANCE_FILES) {
const filePath = join(workspace, file.path);
touch(filePath, file.isDirectory);
}
this.governanceFilesInitialized = true;
}
isKnownSafeCommand(args: string[]): boolean { isKnownSafeCommand(args: string[]): boolean {
return isKnownSafeCommand(args); return isKnownSafeCommand(args);
} }
@@ -258,17 +275,14 @@ export class LinuxSandboxManager implements SandboxManager {
mergedAdditional, mergedAdditional,
); );
for (const file of GOVERNANCE_FILES) { this.ensureGovernanceFilesExist(resolvedPaths.workspace.resolved);
const filePath = join(this.options.workspace, file.path);
touch(filePath, file.isDirectory);
}
const bwrapArgs = await buildBwrapArgs({ const bwrapArgs = await buildBwrapArgs({
resolvedPaths, resolvedPaths,
workspaceWrite, workspaceWrite,
networkAccess: mergedAdditional.network ?? false, networkAccess: mergedAdditional.network ?? false,
maskFilePath: this.getMaskFilePath(), maskFilePath: this.getMaskFilePath(),
isWriteCommand: req.command === '__write', isReadOnlyCommand: req.command === '__read',
}); });
const bpfPath = getSeccompBpfPath(); const bpfPath = getSeccompBpfPath();
@@ -92,7 +92,7 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
workspaceWrite: false, workspaceWrite: false,
networkAccess: false, networkAccess: false,
maskFilePath: '/tmp/mask', maskFilePath: '/tmp/mask',
isWriteCommand: false, isReadOnlyCommand: false,
}; };
it('should correctly format the base arguments', async () => { it('should correctly format the base arguments', async () => {
@@ -188,7 +188,7 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
expect(args[args.indexOf('/opt/tools') - 1]).toBe('--bind-try'); expect(args[args.indexOf('/opt/tools') - 1]).toBe('--bind-try');
}); });
it('should bind the parent directory of a non-existent path', async () => { it('should bind the parent directory of a non-existent path with --bind-try when isReadOnlyCommand is false', async () => {
vi.mocked(fs.existsSync).mockImplementation((p) => { vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/workspace/new-file.txt') return false; if (p === '/home/user/workspace/new-file.txt') return false;
return true; return true;
@@ -196,10 +196,10 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
const args = await buildBwrapArgs({ const args = await buildBwrapArgs({
...defaultOptions, ...defaultOptions,
isReadOnlyCommand: false,
resolvedPaths: createResolvedPaths({ resolvedPaths: createResolvedPaths({
policyAllowed: ['/home/user/workspace/new-file.txt'], policyAllowed: ['/home/user/workspace/new-file.txt'],
}), }),
isWriteCommand: true,
}); });
const parentDir = '/home/user/workspace'; const parentDir = '/home/user/workspace';
@@ -208,6 +208,26 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
expect(args[bindIndex - 2]).toBe('--bind-try'); expect(args[bindIndex - 2]).toBe('--bind-try');
}); });
it('should bind the parent directory of a non-existent path with --ro-bind-try when isReadOnlyCommand is true', async () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/workspace/new-file.txt') return false;
return true;
});
const args = await buildBwrapArgs({
...defaultOptions,
isReadOnlyCommand: true,
resolvedPaths: createResolvedPaths({
policyAllowed: ['/home/user/workspace/new-file.txt'],
}),
});
const parentDir = '/home/user/workspace';
const bindIndex = args.lastIndexOf(parentDir);
expect(bindIndex).not.toBe(-1);
expect(args[bindIndex - 2]).toBe('--ro-bind-try');
});
it('should parameterize forbidden paths and explicitly deny them', async () => { it('should parameterize forbidden paths and explicitly deny them', async () => {
vi.mocked(fs.statSync).mockImplementation((p) => { vi.mocked(fs.statSync).mockImplementation((p) => {
if (p.toString().includes('cache')) { if (p.toString().includes('cache')) {
@@ -23,7 +23,7 @@ export interface BwrapArgsOptions {
workspaceWrite: boolean; workspaceWrite: boolean;
networkAccess: boolean; networkAccess: boolean;
maskFilePath: string; maskFilePath: string;
isWriteCommand: boolean; isReadOnlyCommand: boolean;
} }
/** /**
@@ -37,7 +37,7 @@ export async function buildBwrapArgs(
workspaceWrite, workspaceWrite,
networkAccess, networkAccess,
maskFilePath, maskFilePath,
isWriteCommand, isReadOnlyCommand,
} = options; } = options;
const { workspace } = resolvedPaths; const { workspace } = resolvedPaths;
@@ -79,10 +79,13 @@ export async function buildBwrapArgs(
bwrapArgs.push('--bind-try', allowedPath, allowedPath); bwrapArgs.push('--bind-try', allowedPath, allowedPath);
} else { } else {
// If the path doesn't exist, we still want to allow access to its parent // If the path doesn't exist, we still want to allow access to its parent
// to enable creating it. Since allowedPath is already resolved by resolveSandboxPaths, // to enable creating it.
// its parent is also correctly resolved.
const parent = dirname(allowedPath); const parent = dirname(allowedPath);
bwrapArgs.push(isWriteCommand ? '--bind-try' : bindFlag, parent, parent); bwrapArgs.push(
isReadOnlyCommand ? '--ro-bind-try' : '--bind-try',
parent,
parent,
);
} }
} }
@@ -5,7 +5,7 @@
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MacOsSandboxManager } from './MacOsSandboxManager.js'; import { MacOsSandboxManager } from './MacOsSandboxManager.js';
import type { ExecutionPolicy } from '../../services/sandboxManager.js'; import { type ExecutionPolicy } from '../../services/sandboxManager.js';
import * as seatbeltArgsBuilder from './seatbeltArgsBuilder.js'; import * as seatbeltArgsBuilder from './seatbeltArgsBuilder.js';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
@@ -191,28 +191,6 @@ describe('MacOsSandboxManager', () => {
}); });
}); });
describe('governance files', () => {
it('should ensure governance files exist', async () => {
await manager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: mockPolicy,
});
// The seatbelt builder internally handles governance files, so we simply verify
// it is invoked correctly with the right workspace.
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
expect.objectContaining({
resolvedPaths: expect.objectContaining({
workspace: { resolved: mockWorkspace, original: mockWorkspace },
}),
}),
);
});
});
describe('allowedPaths', () => { describe('allowedPaths', () => {
it('should parameterize allowed paths and normalize them', async () => { it('should parameterize allowed paths and normalize them', async () => {
await manager.prepareCommand({ await manager.prepareCommand({
@@ -5,7 +5,7 @@
*/ */
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path, { join } from 'node:path';
import os from 'node:os'; import os from 'node:os';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { import {
@@ -33,6 +33,7 @@ import {
} from './commandSafety.js'; } from './commandSafety.js';
import { verifySandboxOverrides } from '../utils/commandUtils.js'; import { verifySandboxOverrides } from '../utils/commandUtils.js';
import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js'; import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js';
import { isErrnoException } from '../utils/fsUtils.js';
import { import {
isSubpath, isSubpath,
resolveToRealPath, resolveToRealPath,
@@ -53,10 +54,13 @@ const __dirname = path.dirname(__filename);
*/ */
export class WindowsSandboxManager implements SandboxManager { export class WindowsSandboxManager implements SandboxManager {
static readonly HELPER_EXE = 'GeminiSandbox.exe'; static readonly HELPER_EXE = 'GeminiSandbox.exe';
private readonly helperPath: string; private readonly helperPath: string;
private initialized = false;
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache(); private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
private static helperCompiled = false;
private governanceFilesInitialized = false;
constructor(private readonly options: GlobalSandboxOptions) { constructor(private readonly options: GlobalSandboxOptions) {
this.helperPath = path.resolve(__dirname, WindowsSandboxManager.HELPER_EXE); this.helperPath = path.resolve(__dirname, WindowsSandboxManager.HELPER_EXE);
} }
@@ -86,33 +90,20 @@ export class WindowsSandboxManager implements SandboxManager {
return this.options; return this.options;
} }
/** private ensureGovernanceFilesExist(workspace: string): void {
* Ensures a file or directory exists. if (this.governanceFilesInitialized) return;
*/
private touch(filePath: string, isDirectory: boolean): void { // These must exist on the host before running the sandbox to ensure they are protected.
assertValidPathString(filePath); for (const file of GOVERNANCE_FILES) {
try { const filePath = join(workspace, file.path);
// If it exists (even as a broken symlink), do nothing touch(filePath, file.isDirectory);
if (fs.lstatSync(filePath)) return;
} catch {
// Ignore ENOENT
} }
if (isDirectory) { this.governanceFilesInitialized = true;
fs.mkdirSync(filePath, { recursive: true });
} else {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.closeSync(fs.openSync(filePath, 'a'));
}
} }
private async ensureInitialized(): Promise<void> { private async ensureHelperCompiled(): Promise<void> {
if (this.initialized) return; if (WindowsSandboxManager.helperCompiled || os.platform() !== 'win32') {
if (os.platform() !== 'win32') {
this.initialized = true;
return; return;
} }
@@ -207,14 +198,14 @@ export class WindowsSandboxManager implements SandboxManager {
); );
} }
this.initialized = true; WindowsSandboxManager.helperCompiled = true;
} }
/** /**
* Prepares a command for sandboxed execution on Windows. * Prepares a command for sandboxed execution on Windows.
*/ */
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> { async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
await this.ensureInitialized(); await this.ensureHelperCompiled();
const sanitizationConfig = getSecureSanitizationConfig( const sanitizationConfig = getSecureSanitizationConfig(
req.policy?.sanitizationConfig, req.policy?.sanitizationConfig,
@@ -276,6 +267,8 @@ export class WindowsSandboxManager implements SandboxManager {
mergedAdditional, mergedAdditional,
); );
this.ensureGovernanceFilesExist(resolvedPaths.workspace.resolved);
// 1. Collect all forbidden paths. // 1. Collect all forbidden paths.
// We start with explicitly forbidden paths from the options and request. // We start with explicitly forbidden paths from the options and request.
const forbiddenManifest = new Set( const forbiddenManifest = new Set(
@@ -402,14 +395,6 @@ export class WindowsSandboxManager implements SandboxManager {
// No-op for read access on Windows. // No-op for read access on Windows.
} }
// 4. Protected governance files
// These must exist on the host before running the sandbox to prevent
// the sandboxed process from creating them with Low integrity.
for (const file of GOVERNANCE_FILES) {
const filePath = path.join(resolvedPaths.workspace.resolved, file.path);
this.touch(filePath, file.isDirectory);
}
// 5. Generate Manifests // 5. Generate Manifests
const tempDir = await fs.promises.mkdtemp( const tempDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'gemini-cli-sandbox-'), path.join(os.tmpdir(), 'gemini-cli-sandbox-'),
@@ -471,3 +456,29 @@ export class WindowsSandboxManager implements SandboxManager {
); );
} }
} }
/**
* Ensures a file or directory exists.
*/
function touch(filePath: string, isDirectory: boolean): void {
assertValidPathString(filePath);
try {
// If it exists (even as a broken symlink), do nothing
fs.lstatSync(filePath);
return;
} catch (e: unknown) {
if (isErrnoException(e) && e.code !== 'ENOENT') {
throw e;
}
}
if (isDirectory) {
fs.mkdirSync(filePath, { recursive: true });
} else {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.closeSync(fs.openSync(filePath, 'a'));
}
}
@@ -53,7 +53,7 @@ vi.mock('../utils/events.js', () => ({
})); }));
vi.mock('../utils/debugLogger.js', () => ({ vi.mock('../utils/debugLogger.js', () => ({
debugLogger: { log: vi.fn() }, debugLogger: { debug: vi.fn() },
})); }));
vi.mock('node:os', async (importOriginal) => { vi.mock('node:os', async (importOriginal) => {
@@ -153,14 +153,14 @@ describe('KeychainService', () => {
// Because it falls back to FileKeychain, it is always available. // Because it falls back to FileKeychain, it is always available.
expect(available).toBe(true); expect(available).toBe(true);
expect(debugLogger.log).toHaveBeenCalledWith( expect(debugLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('encountered an error'), expect.stringContaining('encountered an error'),
'locked', 'locked',
); );
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith( expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
expect.objectContaining({ available: false }), expect.objectContaining({ available: false }),
); );
expect(debugLogger.log).toHaveBeenCalledWith( expect(debugLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('Using FileKeychain fallback'), expect.stringContaining('Using FileKeychain fallback'),
); );
expect(FileKeychain).toHaveBeenCalled(); expect(FileKeychain).toHaveBeenCalled();
@@ -173,7 +173,7 @@ describe('KeychainService', () => {
const available = await service.isAvailable(); const available = await service.isAvailable();
expect(available).toBe(true); expect(available).toBe(true);
expect(debugLogger.log).toHaveBeenCalledWith( expect(debugLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('failed structural validation'), expect.stringContaining('failed structural validation'),
expect.objectContaining({ getPassword: expect.any(Array) }), expect.objectContaining({ getPassword: expect.any(Array) }),
); );
@@ -191,7 +191,7 @@ describe('KeychainService', () => {
const available = await service.isAvailable(); const available = await service.isAvailable();
expect(available).toBe(true); expect(available).toBe(true);
expect(debugLogger.log).toHaveBeenCalledWith( expect(debugLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('functional verification failed'), expect.stringContaining('functional verification failed'),
); );
expect(FileKeychain).toHaveBeenCalled(); expect(FileKeychain).toHaveBeenCalled();
@@ -243,7 +243,7 @@ describe('KeychainService', () => {
); );
expect(mockKeytar.setPassword).not.toHaveBeenCalled(); expect(mockKeytar.setPassword).not.toHaveBeenCalled();
expect(FileKeychain).toHaveBeenCalled(); expect(FileKeychain).toHaveBeenCalled();
expect(debugLogger.log).toHaveBeenCalledWith( expect(debugLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('MacOS default keychain not found'), expect.stringContaining('MacOS default keychain not found'),
); );
}); });
@@ -114,7 +114,7 @@ export class KeychainService {
} }
// If native failed or was skipped, return the secure file fallback. // If native failed or was skipped, return the secure file fallback.
debugLogger.log('Using FileKeychain fallback for secure storage.'); debugLogger.debug('Using FileKeychain fallback for secure storage.');
return new FileKeychain(); return new FileKeychain();
} }
@@ -130,7 +130,7 @@ export class KeychainService {
// Probing macOS prevents process-blocking popups when no keychain exists. // Probing macOS prevents process-blocking popups when no keychain exists.
if (os.platform() === 'darwin' && !this.isMacOSKeychainAvailable()) { if (os.platform() === 'darwin' && !this.isMacOSKeychainAvailable()) {
debugLogger.log( debugLogger.debug(
'MacOS default keychain not found; skipping functional verification.', 'MacOS default keychain not found; skipping functional verification.',
); );
return null; return null;
@@ -140,12 +140,15 @@ export class KeychainService {
return keychainModule; return keychainModule;
} }
debugLogger.log('Keychain functional verification failed'); debugLogger.debug('Keychain functional verification failed');
return null; return null;
} catch (error) { } catch (error) {
// Avoid logging full error objects to prevent PII exposure. // Avoid logging full error objects to prevent PII exposure.
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
debugLogger.log('Keychain initialization encountered an error:', message); debugLogger.debug(
'Keychain initialization encountered an error:',
message,
);
return null; return null;
} }
} }
@@ -162,7 +165,7 @@ export class KeychainService {
return potential as Keychain; return potential as Keychain;
} }
debugLogger.log( debugLogger.debug(
'Keychain module failed structural validation:', 'Keychain module failed structural validation:',
result.error.flatten().fieldErrors, result.error.flatten().fieldErrors,
); );
File diff suppressed because it is too large Load Diff
@@ -20,6 +20,8 @@ import { MCPOAuthTokenStorage } from '../mcp/oauth-token-storage.js';
import { OAuthUtils } from '../mcp/oauth-utils.js'; import { OAuthUtils } from '../mcp/oauth-utils.js';
import type { PromptRegistry } from '../prompts/prompt-registry.js'; import type { PromptRegistry } from '../prompts/prompt-registry.js';
import { import {
ErrorCode,
McpError,
PromptListChangedNotificationSchema, PromptListChangedNotificationSchema,
ResourceListChangedNotificationSchema, ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema, ToolListChangedNotificationSchema,
@@ -35,6 +37,7 @@ import {
isEnabled, isEnabled,
McpClient, McpClient,
populateMcpServerCommand, populateMcpServerCommand,
discoverPrompts,
type McpContext, type McpContext,
} from './mcp-client.js'; } from './mcp-client.js';
import type { ToolRegistry } from './tool-registry.js'; import type { ToolRegistry } from './tool-registry.js';
@@ -320,6 +323,25 @@ describe('mcp-client', () => {
); );
}); });
it('should return empty array for discoverPrompts on MethodNotFound error without diagnostic', async () => {
const mockedClient = {
getServerCapabilities: vi.fn().mockReturnValue({ prompts: {} }),
listPrompts: vi
.fn()
.mockRejectedValue(
new McpError(ErrorCode.MethodNotFound, 'Method not supported'),
),
};
const result = await discoverPrompts(
'test-server',
mockedClient as unknown as ClientLib.Client,
MOCK_CONTEXT,
);
expect(result).toEqual([]);
// MethodNotFound errors should be silently ignored regardless of message text
expect(MOCK_CONTEXT.emitMcpDiagnostic).not.toHaveBeenCalled();
});
it('should not discover tools if server does not support them', async () => { it('should not discover tools if server does not support them', async () => {
const mockedClient = { const mockedClient = {
connect: vi.fn(), connect: vi.fn(),
+9 -7
View File
@@ -27,6 +27,8 @@ import {
ReadResourceResultSchema, ReadResourceResultSchema,
ResourceListChangedNotificationSchema, ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema, ToolListChangedNotificationSchema,
ErrorCode,
McpError,
PromptListChangedNotificationSchema, PromptListChangedNotificationSchema,
ProgressNotificationSchema, ProgressNotificationSchema,
type GetPromptResult, type GetPromptResult,
@@ -1250,6 +1252,10 @@ export async function connectAndDiscover(
} }
} }
function isMcpMethodNotFoundError(error: unknown): boolean {
return error instanceof McpError && error.code === ErrorCode.MethodNotFound;
}
/** /**
* Discovers and sanitizes tools from a connected MCP client. * Discovers and sanitizes tools from a connected MCP client.
* It retrieves function declarations from the client, filters out disabled tools, * It retrieves function declarations from the client, filters out disabled tools,
@@ -1329,10 +1335,7 @@ export async function discoverTools(
} }
return discoveredTools; return discoveredTools;
} catch (error) { } catch (error) {
if ( if (!isMcpMethodNotFoundError(error)) {
error instanceof Error &&
!error.message?.includes('Method not found')
) {
cliConfig.emitMcpDiagnostic( cliConfig.emitMcpDiagnostic(
'error', 'error',
`Error discovering tools from ${mcpServerName}: ${getErrorMessage( `Error discovering tools from ${mcpServerName}: ${getErrorMessage(
@@ -1456,8 +1459,7 @@ export async function discoverPrompts(
), ),
})); }));
} catch (error) { } catch (error) {
// It's okay if the method is not found, which is a common case. if (isMcpMethodNotFoundError(error)) {
if (error instanceof Error && error.message?.includes('Method not found')) {
return []; return [];
} }
cliConfig.emitMcpDiagnostic( cliConfig.emitMcpDiagnostic(
@@ -1505,7 +1507,7 @@ async function listResources(
cursor = response.nextCursor ?? undefined; cursor = response.nextCursor ?? undefined;
} while (cursor); } while (cursor);
} catch (error) { } catch (error) {
if (error instanceof Error && error.message?.includes('Method not found')) { if (isMcpMethodNotFoundError(error)) {
return []; return [];
} }
cliConfig.emitMcpDiagnostic( cliConfig.emitMcpDiagnostic(
@@ -195,6 +195,7 @@ describe('compatibility', () => {
desc: '256 colors are not supported', desc: '256 colors are not supported',
}, },
])('should return $expected when $desc', ({ depth, term, expected }) => { ])('should return $expected when $desc', ({ depth, term, expected }) => {
vi.stubEnv('COLORTERM', '');
process.stdout.getColorDepth = vi.fn().mockReturnValue(depth); process.stdout.getColorDepth = vi.fn().mockReturnValue(depth);
if (term !== undefined) { if (term !== undefined) {
vi.stubEnv('TERM', term); vi.stubEnv('TERM', term);
@@ -203,6 +204,13 @@ describe('compatibility', () => {
} }
expect(supports256Colors()).toBe(expected); expect(supports256Colors()).toBe(expected);
}); });
it('should return true when COLORTERM is kmscon', () => {
process.stdout.getColorDepth = vi.fn().mockReturnValue(4);
vi.stubEnv('TERM', 'linux');
vi.stubEnv('COLORTERM', 'kmscon');
expect(supports256Colors()).toBe(true);
});
}); });
describe('supportsTrueColor', () => { describe('supportsTrueColor', () => {
@@ -230,6 +238,12 @@ describe('compatibility', () => {
expected: true, expected: true,
desc: 'getColorDepth returns >= 24', desc: 'getColorDepth returns >= 24',
}, },
{
colorterm: 'kmscon',
depth: 4,
expected: true,
desc: 'COLORTERM is kmscon',
},
{ {
colorterm: '', colorterm: '',
depth: 8, depth: 8,
@@ -409,6 +423,18 @@ describe('compatibility', () => {
); );
}); });
it('should return no color warnings for kmscon terminal', () => {
vi.mocked(os.platform).mockReturnValue('linux');
vi.stubEnv('TERMINAL_EMULATOR', '');
vi.stubEnv('TERM', 'linux');
vi.stubEnv('COLORTERM', 'kmscon');
process.stdout.getColorDepth = vi.fn().mockReturnValue(4);
const warnings = getCompatibilityWarnings();
expect(warnings.find((w) => w.id === '256-color')).toBeUndefined();
expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined();
});
it('should return no warnings in a standard environment with true color', () => { it('should return no warnings in a standard environment with true color', () => {
vi.mocked(os.platform).mockReturnValue('darwin'); vi.mocked(os.platform).mockReturnValue('darwin');
vi.stubEnv('TERMINAL_EMULATOR', ''); vi.stubEnv('TERMINAL_EMULATOR', '');
+7 -1
View File
@@ -85,6 +85,11 @@ export function supports256Colors(): boolean {
return true; return true;
} }
// Terminals supporting true color (like kmscon) also support 256 colors
if (supportsTrueColor()) {
return true;
}
return false; return false;
} }
@@ -95,7 +100,8 @@ export function supportsTrueColor(): boolean {
// Check COLORTERM environment variable // Check COLORTERM environment variable
if ( if (
process.env['COLORTERM'] === 'truecolor' || process.env['COLORTERM'] === 'truecolor' ||
process.env['COLORTERM'] === '24bit' process.env['COLORTERM'] === '24bit' ||
process.env['COLORTERM'] === 'kmscon'
) { ) {
return true; return true;
} }
+5
View File
@@ -12,6 +12,11 @@
"cpuTotalUs": 12157, "cpuTotalUs": 12157,
"timestamp": "2026-04-08T22:28:19.098Z" "timestamp": "2026-04-08T22:28:19.098Z"
}, },
"asian-language-conv": {
"wallClockMs": 2315.1,
"cpuTotalUs": 6283,
"timestamp": "2026-04-14T15:22:56.133Z"
},
"skill-loading-time": { "skill-loading-time": {
"wallClockMs": 930.0920409999962, "wallClockMs": 930.0920409999962,
"cpuTotalUs": 1323, "cpuTotalUs": 1323,
+30
View File
@@ -98,6 +98,36 @@ describe('CPU Performance Tests', () => {
} }
}); });
it('asian-language-conv: verify perf is acceptable ', async () => {
const result = await harness.runScenario(
'asian-language-conv',
async () => {
const rig = new TestRig();
try {
rig.setup('perf-asian-language', {
fakeResponsesPath: join(__dirname, 'perf.asian-language.responses'),
});
return await harness.measure('asian-language', async () => {
await rig.run({
args: ['嗨'],
timeout: 120000,
env: { GEMINI_API_KEY: 'fake-perf-test-key' },
});
});
} finally {
await rig.cleanup();
}
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
} else {
harness.assertWithinBaseline(result);
}
});
it('skill-loading-time: startup with many skills within baseline', async () => { it('skill-loading-time: startup with many skills within baseline', async () => {
const SKILL_COUNT = 20; const SKILL_COUNT = 20;
+2
View File
@@ -0,0 +1,2 @@
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"你好!我是 Gemini CLI,你的 AI 编程助手"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":20648,"candidatesTokenCount":12,"totalTokenCount":20769,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}