Merge pull request #379 from RooVetGit/auto_approve_menu

Auto approve menu
This commit is contained in:
Matt Rubens
2025-01-16 00:26:02 -05:00
committed by GitHub
9 changed files with 979 additions and 19 deletions

View File

@@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Add auto-approve menu (thanks Cline!)

View File

@@ -93,6 +93,7 @@ export interface ExtensionState {
mode: Mode
modeApiConfigs?: Record<Mode, string>
enhancementApiConfigId?: string
autoApprovalEnabled?: boolean
}
export interface ClineMessage {

View File

@@ -0,0 +1,201 @@
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { useCallback, useState } from "react"
import { useExtensionState } from "../../context/ExtensionStateContext"
interface AutoApproveAction {
id: string
label: string
enabled: boolean
shortName: string
description: string
}
interface AutoApproveMenuProps {
style?: React.CSSProperties
}
const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
const [isExpanded, setIsExpanded] = useState(false)
const {
alwaysAllowReadOnly,
setAlwaysAllowReadOnly,
alwaysAllowWrite,
setAlwaysAllowWrite,
alwaysAllowExecute,
setAlwaysAllowExecute,
alwaysAllowBrowser,
setAlwaysAllowBrowser,
alwaysAllowMcp,
setAlwaysAllowMcp,
alwaysApproveResubmit,
setAlwaysApproveResubmit,
autoApprovalEnabled,
setAutoApprovalEnabled,
} = useExtensionState()
const actions: AutoApproveAction[] = [
{
id: "readFiles",
label: "Read files and directories",
shortName: "Read",
enabled: alwaysAllowReadOnly ?? false,
description: "Allows access to read any file on your computer.",
},
{
id: "editFiles",
label: "Edit files",
shortName: "Edit",
enabled: alwaysAllowWrite ?? false,
description: "Allows modification of any files on your computer.",
},
{
id: "executeCommands",
label: "Execute safe commands",
shortName: "Commands",
enabled: alwaysAllowExecute ?? false,
description:
"Allows execution of approved terminal commands. You can configure this in the settings panel.",
},
{
id: "useBrowser",
label: "Use the browser",
shortName: "Browser",
enabled: alwaysAllowBrowser ?? false,
description: "Allows ability to launch and interact with any website in a headless browser.",
},
{
id: "useMcp",
label: "Use MCP servers",
shortName: "MCP",
enabled: alwaysAllowMcp ?? false,
description: "Allows use of configured MCP servers which may modify filesystem or interact with APIs.",
},
{
id: "retryRequests",
label: "Retry failed requests",
shortName: "Retries",
enabled: alwaysApproveResubmit ?? false,
description: "Automatically retry failed API requests when the provider returns an error response.",
},
]
const toggleExpanded = useCallback(() => {
setIsExpanded((prev) => !prev)
}, [])
const enabledActionsList = actions
.filter((action) => action.enabled)
.map((action) => action.shortName)
.join(", ")
// Individual checkbox handlers - each one only updates its own state
const handleReadOnlyChange = useCallback(() => setAlwaysAllowReadOnly(!(alwaysAllowReadOnly ?? false)), [alwaysAllowReadOnly, setAlwaysAllowReadOnly])
const handleWriteChange = useCallback(() => setAlwaysAllowWrite(!(alwaysAllowWrite ?? false)), [alwaysAllowWrite, setAlwaysAllowWrite])
const handleExecuteChange = useCallback(() => setAlwaysAllowExecute(!(alwaysAllowExecute ?? false)), [alwaysAllowExecute, setAlwaysAllowExecute])
const handleBrowserChange = useCallback(() => setAlwaysAllowBrowser(!(alwaysAllowBrowser ?? false)), [alwaysAllowBrowser, setAlwaysAllowBrowser])
const handleMcpChange = useCallback(() => setAlwaysAllowMcp(!(alwaysAllowMcp ?? false)), [alwaysAllowMcp, setAlwaysAllowMcp])
const handleRetryChange = useCallback(() => setAlwaysApproveResubmit(!(alwaysApproveResubmit ?? false)), [alwaysApproveResubmit, setAlwaysApproveResubmit])
// Map action IDs to their specific handlers
const actionHandlers: Record<AutoApproveAction['id'], () => void> = {
readFiles: handleReadOnlyChange,
editFiles: handleWriteChange,
executeCommands: handleExecuteChange,
useBrowser: handleBrowserChange,
useMcp: handleMcpChange,
retryRequests: handleRetryChange,
}
return (
<div
style={{
padding: "0 15px",
userSelect: "none",
borderTop: isExpanded
? `0.5px solid color-mix(in srgb, var(--vscode-titleBar-inactiveForeground) 20%, transparent)`
: "none",
overflowY: "auto",
...style,
}}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: isExpanded ? "8px 0" : "8px 0 0 0",
cursor: "pointer",
}}
onClick={toggleExpanded}>
<div onClick={(e) => e.stopPropagation()}>
<VSCodeCheckbox
checked={autoApprovalEnabled ?? false}
onChange={() => setAutoApprovalEnabled(!(autoApprovalEnabled ?? false))}
/>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
flex: 1,
minWidth: 0
}}>
<span style={{
color: "var(--vscode-foreground)",
flexShrink: 0
}}>Auto-approve:</span>
<span style={{
color: "var(--vscode-descriptionForeground)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
minWidth: 0
}}>
{enabledActionsList || "None"}
</span>
<span
className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}
style={{
flexShrink: 0,
marginLeft: isExpanded ? "2px" : "-2px",
}}
/>
</div>
</div>
{isExpanded && (
<div style={{ padding: "0" }}>
<div
style={{
marginBottom: "10px",
color: "var(--vscode-descriptionForeground)",
fontSize: "12px",
}}>
Auto-approve allows Cline to perform actions without asking for permission. Only enable for
actions you fully trust.
</div>
{actions.map((action) => (
<div key={action.id} style={{ margin: "6px 0" }}>
<div onClick={(e) => e.stopPropagation()}>
<VSCodeCheckbox
checked={action.enabled}
onChange={actionHandlers[action.id]}>
{action.label}
</VSCodeCheckbox>
</div>
<div
style={{
marginLeft: "28px",
color: "var(--vscode-descriptionForeground)",
fontSize: "12px",
}}>
{action.description}
</div>
</div>
))}
</div>
)}
</div>
)
}
export default AutoApproveMenu

View File

@@ -527,7 +527,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
flexDirection: "column",
gap: "8px",
backgroundColor: "var(--vscode-input-background)",
minHeight: "100px",
margin: "10px 15px",
padding: "8px"
}}
@@ -652,7 +651,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
onHeightChange?.(height)
}}
placeholder={placeholderText}
minRows={4}
minRows={2}
maxRows={20}
autoFocus={true}
style={{

View File

@@ -25,6 +25,7 @@ import BrowserSessionRow from "./BrowserSessionRow"
import ChatRow from "./ChatRow"
import ChatTextArea from "./ChatTextArea"
import TaskHeader from "./TaskHeader"
import AutoApproveMenu from "./AutoApproveMenu"
import { AudioType } from "../../../../src/shared/WebviewMessage"
import { validateCommand } from "../../utils/command-validation"
@@ -38,7 +39,7 @@ interface ChatViewProps {
export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
const { version, clineMessages: messages, taskHistory, apiConfiguration, mcpServers, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, allowedCommands, writeDelayMs, mode, setMode } = useExtensionState()
const { version, clineMessages: messages, taskHistory, apiConfiguration, mcpServers, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, allowedCommands, writeDelayMs, mode, setMode, autoApprovalEnabled } = useExtensionState()
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
@@ -528,7 +529,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const isAutoApproved = useCallback(
(message: ClineMessage | undefined) => {
if (!message || message.type !== "ask") return false
if (!autoApprovalEnabled || !message || message.type !== "ask") return false
return (
(alwaysAllowBrowser && message.ask === "browser_action_launch") ||
@@ -538,17 +539,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
(alwaysAllowMcp && message.ask === "use_mcp_server" && isMcpToolAlwaysAllowed(message))
)
},
[
alwaysAllowBrowser,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowMcp,
isReadOnlyToolAction,
isWriteToolAction,
isAllowedCommand,
isMcpToolAlwaysAllowed
]
[autoApprovalEnabled, alwaysAllowBrowser, alwaysAllowReadOnly, isReadOnlyToolAction, alwaysAllowWrite, isWriteToolAction, alwaysAllowExecute, isAllowedCommand, alwaysAllowMcp, isMcpToolAlwaysAllowed]
)
useEffect(() => {
@@ -866,10 +857,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
) : (
<div
style={{
flexGrow: 1,
flex: "1 1 0", // flex-grow: 1, flex-shrink: 1, flex-basis: 0
minHeight: 0,
overflowY: "auto",
display: "flex",
flexDirection: "column",
paddingBottom: "10px",
}}>
{showAnnouncement && <Announcement version={version} hideAnnouncement={hideAnnouncement} />}
<div style={{ padding: "0 20px", flexShrink: 0 }}>
@@ -885,6 +878,32 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
{taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}
</div>
)}
{/*
// Flex layout explanation:
// 1. Content div above uses flex: "1 1 0" to:
// - Grow to fill available space (flex-grow: 1)
// - Shrink when AutoApproveMenu needs space (flex-shrink: 1)
// - Start from zero size (flex-basis: 0) to ensure proper distribution
// minHeight: 0 allows it to shrink below its content height
//
// 2. AutoApproveMenu uses flex: "0 1 auto" to:
// - Not grow beyond its content (flex-grow: 0)
// - Shrink when viewport is small (flex-shrink: 1)
// - Use its content size as basis (flex-basis: auto)
// This ensures it takes its natural height when there's space
// but becomes scrollable when the viewport is too small
*/}
{!task && (
<AutoApproveMenu
style={{
marginBottom: -2,
flex: "0 1 auto", // flex-grow: 0, flex-shrink: 1, flex-basis: auto
minHeight: 0,
}}
/>
)}
{task && (
<>
<div style={{ flexGrow: 1, display: "flex" }} ref={scrollContainerRef}>
@@ -914,6 +933,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
initialTopMostItemIndex={groupedMessages.length - 1}
/>
</div>
<AutoApproveMenu />
{showScrollToBottom ? (
<div
style={{
@@ -938,7 +958,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
: 0.5
: 0,
display: "flex",
padding: "10px 15px 0px 15px",
padding: `${primaryButtonText || secondaryButtonText || isStreaming ? "10" : "0"}px 15px 0px 15px`,
}}>
{primaryButtonText && !isStreaming && (
<VSCodeButton

View File

@@ -0,0 +1,198 @@
import { render, fireEvent, screen } from "@testing-library/react"
import { useExtensionState } from "../../../context/ExtensionStateContext"
import AutoApproveMenu from "../AutoApproveMenu"
import { codeMode, defaultPrompts } from "../../../../../src/shared/modes"
// Mock the ExtensionStateContext hook
jest.mock("../../../context/ExtensionStateContext")
const mockUseExtensionState = useExtensionState as jest.MockedFunction<typeof useExtensionState>
describe("AutoApproveMenu", () => {
const defaultMockState = {
// Required state properties
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
soundEnabled: false,
soundVolume: 0.5,
diffEnabled: false,
fuzzyMatchThreshold: 1.0,
preferredLanguage: "English",
writeDelayMs: 1000,
browserViewportSize: "900x600",
screenshotQuality: 75,
terminalOutputLineLimit: 500,
mcpEnabled: true,
requestDelaySeconds: 5,
currentApiConfigName: "default",
listApiConfigMeta: [],
mode: codeMode,
customPrompts: defaultPrompts,
enhancementApiConfigId: "",
didHydrateState: true,
showWelcome: false,
theme: {},
glamaModels: {},
openRouterModels: {},
openAiModels: [],
mcpServers: [],
filePaths: [],
// Auto-approve specific properties
alwaysAllowReadOnly: false,
alwaysAllowWrite: false,
alwaysAllowExecute: false,
alwaysAllowBrowser: false,
alwaysAllowMcp: false,
alwaysApproveResubmit: false,
autoApprovalEnabled: false,
// Required setter functions
setApiConfiguration: jest.fn(),
setCustomInstructions: jest.fn(),
setAlwaysAllowReadOnly: jest.fn(),
setAlwaysAllowWrite: jest.fn(),
setAlwaysAllowExecute: jest.fn(),
setAlwaysAllowBrowser: jest.fn(),
setAlwaysAllowMcp: jest.fn(),
setShowAnnouncement: jest.fn(),
setAllowedCommands: jest.fn(),
setSoundEnabled: jest.fn(),
setSoundVolume: jest.fn(),
setDiffEnabled: jest.fn(),
setBrowserViewportSize: jest.fn(),
setFuzzyMatchThreshold: jest.fn(),
setPreferredLanguage: jest.fn(),
setWriteDelayMs: jest.fn(),
setScreenshotQuality: jest.fn(),
setTerminalOutputLineLimit: jest.fn(),
setMcpEnabled: jest.fn(),
setAlwaysApproveResubmit: jest.fn(),
setRequestDelaySeconds: jest.fn(),
setCurrentApiConfigName: jest.fn(),
setListApiConfigMeta: jest.fn(),
onUpdateApiConfig: jest.fn(),
setMode: jest.fn(),
setCustomPrompts: jest.fn(),
setEnhancementApiConfigId: jest.fn(),
setAutoApprovalEnabled: jest.fn(),
}
beforeEach(() => {
mockUseExtensionState.mockReturnValue(defaultMockState)
})
afterEach(() => {
jest.clearAllMocks()
})
it("renders with initial collapsed state", () => {
render(<AutoApproveMenu />)
// Check for main checkbox and label
expect(screen.getByText("Auto-approve:")).toBeInTheDocument()
expect(screen.getByText("None")).toBeInTheDocument()
// Verify the menu is collapsed (actions not visible)
expect(screen.queryByText("Read files and directories")).not.toBeInTheDocument()
})
it("expands menu when clicked", () => {
render(<AutoApproveMenu />)
// Click to expand
fireEvent.click(screen.getByText("Auto-approve:"))
// Verify menu items are visible
expect(screen.getByText("Read files and directories")).toBeInTheDocument()
expect(screen.getByText("Edit files")).toBeInTheDocument()
expect(screen.getByText("Execute safe commands")).toBeInTheDocument()
expect(screen.getByText("Use the browser")).toBeInTheDocument()
expect(screen.getByText("Use MCP servers")).toBeInTheDocument()
expect(screen.getByText("Retry failed requests")).toBeInTheDocument()
})
it("toggles main auto-approval checkbox", () => {
render(<AutoApproveMenu />)
const mainCheckbox = screen.getByRole("checkbox")
fireEvent.click(mainCheckbox)
expect(defaultMockState.setAutoApprovalEnabled).toHaveBeenCalledWith(true)
})
it("toggles individual permissions", () => {
render(<AutoApproveMenu />)
// Expand menu
fireEvent.click(screen.getByText("Auto-approve:"))
// Click read files checkbox
fireEvent.click(screen.getByText("Read files and directories"))
expect(defaultMockState.setAlwaysAllowReadOnly).toHaveBeenCalledWith(true)
// Click edit files checkbox
fireEvent.click(screen.getByText("Edit files"))
expect(defaultMockState.setAlwaysAllowWrite).toHaveBeenCalledWith(true)
// Click execute commands checkbox
fireEvent.click(screen.getByText("Execute safe commands"))
expect(defaultMockState.setAlwaysAllowExecute).toHaveBeenCalledWith(true)
})
it("displays enabled actions in summary", () => {
mockUseExtensionState.mockReturnValue({
...defaultMockState,
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
autoApprovalEnabled: true,
})
render(<AutoApproveMenu />)
// Check that enabled actions are shown in summary
expect(screen.getByText("Read, Edit")).toBeInTheDocument()
})
it("preserves checkbox states", () => {
// Mock state with some permissions enabled
const mockState = {
...defaultMockState,
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
}
// Update mock to return our state
mockUseExtensionState.mockReturnValue(mockState)
render(<AutoApproveMenu />)
// Expand menu
fireEvent.click(screen.getByText("Auto-approve:"))
// Verify read and edit checkboxes are checked
expect(screen.getByLabelText("Read files and directories")).toBeInTheDocument()
expect(screen.getByLabelText("Edit files")).toBeInTheDocument()
// Verify the setters haven't been called yet
expect(mockState.setAlwaysAllowReadOnly).not.toHaveBeenCalled()
expect(mockState.setAlwaysAllowWrite).not.toHaveBeenCalled()
// Collapse menu
fireEvent.click(screen.getByText("Auto-approve:"))
// Expand again
fireEvent.click(screen.getByText("Auto-approve:"))
// Verify checkboxes are still present
expect(screen.getByLabelText("Read files and directories")).toBeInTheDocument()
expect(screen.getByLabelText("Edit files")).toBeInTheDocument()
// Verify the setters still haven't been called
expect(mockState.setAlwaysAllowReadOnly).not.toHaveBeenCalled()
expect(mockState.setAlwaysAllowWrite).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,313 @@
import React from 'react'
import { render, waitFor } from '@testing-library/react'
import ChatView from '../ChatView'
import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext'
import { vscode } from '../../../utils/vscode'
// Mock vscode API
jest.mock('../../../utils/vscode', () => ({
vscode: {
postMessage: jest.fn(),
},
}))
// Mock all problematic dependencies
jest.mock('rehype-highlight', () => ({
__esModule: true,
default: () => () => {},
}))
jest.mock('hast-util-to-text', () => ({
__esModule: true,
default: () => '',
}))
// Mock components that use ESM dependencies
jest.mock('../BrowserSessionRow', () => ({
__esModule: true,
default: function MockBrowserSessionRow({ messages }: { messages: any[] }) {
return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
}
}))
jest.mock('../ChatRow', () => ({
__esModule: true,
default: function MockChatRow({ message }: { message: any }) {
return <div data-testid="chat-row">{JSON.stringify(message)}</div>
}
}))
jest.mock('../TaskHeader', () => ({
__esModule: true,
default: function MockTaskHeader({ task }: { task: any }) {
return <div data-testid="task-header">{JSON.stringify(task)}</div>
}
}))
jest.mock('../AutoApproveMenu', () => ({
__esModule: true,
default: () => null,
}))
jest.mock('../../common/CodeBlock', () => ({
__esModule: true,
default: () => null,
CODE_BLOCK_BG_COLOR: 'rgb(30, 30, 30)',
}))
jest.mock('../../common/CodeAccordian', () => ({
__esModule: true,
default: () => null,
}))
jest.mock('../ContextMenu', () => ({
__esModule: true,
default: () => null,
}))
// Mock window.postMessage to trigger state hydration
const mockPostMessage = (state: any) => {
window.postMessage({
type: 'state',
state: {
version: '1.0.0',
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
alwaysAllowExecute: false,
autoApprovalEnabled: true,
...state
}
}, '*')
}
describe('ChatView - Auto Approval Tests', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('auto-approves read operations when enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the read tool ask message
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'tool',
ts: Date.now(),
text: JSON.stringify({ tool: 'readFile', path: 'test.txt' }),
partial: false
}
]
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
it('does not auto-approve when autoApprovalEnabled is false', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: false,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the read tool ask message
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: false,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'tool',
ts: Date.now(),
text: JSON.stringify({ tool: 'readFile', path: 'test.txt' }),
partial: false
}
]
})
// Verify no auto-approval message was sent
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
it('auto-approves write operations when enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowWrite: true,
autoApprovalEnabled: true,
writeDelayMs: 0,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the write tool ask message
mockPostMessage({
alwaysAllowWrite: true,
autoApprovalEnabled: true,
writeDelayMs: 0,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'tool',
ts: Date.now(),
text: JSON.stringify({ tool: 'editedExistingFile', path: 'test.txt' }),
partial: false
}
]
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
it('auto-approves browser actions when enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowBrowser: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the browser action ask message
mockPostMessage({
alwaysAllowBrowser: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'browser_action_launch',
ts: Date.now(),
text: JSON.stringify({ action: 'launch', url: 'http://example.com' }),
partial: false
}
]
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
})

View File

@@ -46,6 +46,11 @@ jest.mock('../ChatRow', () => ({
}
}))
jest.mock('../AutoApproveMenu', () => ({
__esModule: true,
default: () => null,
}))
interface ChatTextAreaProps {
onSend: (value: string) => void;
inputValue?: string;
@@ -139,6 +144,187 @@ describe('ChatView - Auto Approval Tests', () => {
jest.clearAllMocks()
})
it('defaults autoApprovalEnabled to true if any individual auto-approval flags are true', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// Test cases with different individual flags
const testCases = [
{ alwaysAllowBrowser: true },
{ alwaysAllowReadOnly: true },
{ alwaysAllowWrite: true },
{ alwaysAllowExecute: true },
{ alwaysAllowMcp: true }
]
for (const flags of testCases) {
// Reset state
mockPostMessage({
...flags,
clineMessages: []
})
// Send an action that should be auto-approved
mockPostMessage({
...flags,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: flags.alwaysAllowBrowser ? 'browser_action_launch' :
flags.alwaysAllowReadOnly ? 'tool' :
flags.alwaysAllowWrite ? 'tool' :
flags.alwaysAllowExecute ? 'command' :
'use_mcp_server',
ts: Date.now(),
text: flags.alwaysAllowBrowser ? JSON.stringify({ action: 'launch', url: 'http://example.com' }) :
flags.alwaysAllowReadOnly ? JSON.stringify({ tool: 'readFile', path: 'test.txt' }) :
flags.alwaysAllowWrite ? JSON.stringify({ tool: 'editedExistingFile', path: 'test.txt' }) :
flags.alwaysAllowExecute ? 'npm test' :
JSON.stringify({ type: 'use_mcp_tool', serverName: 'test', toolName: 'test' }),
partial: false
}
]
})
// Wait for auto-approval
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
}
// Verify no auto-approval when no flags are true
jest.clearAllMocks()
mockPostMessage({
alwaysAllowBrowser: false,
alwaysAllowReadOnly: false,
alwaysAllowWrite: false,
alwaysAllowExecute: false,
alwaysAllowMcp: false,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'browser_action_launch',
ts: Date.now(),
text: JSON.stringify({ action: 'launch', url: 'http://example.com' }),
partial: false
}
]
})
// Wait a bit to ensure no auto-approval happens
await new Promise(resolve => setTimeout(resolve, 100))
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
it('does not auto-approve any actions when autoApprovalEnabled is false', () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: false,
alwaysAllowBrowser: true,
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Test various types of actions that should not be auto-approved
const testCases = [
{
ask: 'browser_action_launch',
text: JSON.stringify({ action: 'launch', url: 'http://example.com' })
},
{
ask: 'tool',
text: JSON.stringify({ tool: 'readFile', path: 'test.txt' })
},
{
ask: 'tool',
text: JSON.stringify({ tool: 'editedExistingFile', path: 'test.txt' })
},
{
ask: 'command',
text: 'npm test'
}
]
testCases.forEach(testCase => {
mockPostMessage({
autoApprovalEnabled: false,
alwaysAllowBrowser: true,
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: testCase.ask,
ts: Date.now(),
text: testCase.text,
partial: false
}
]
})
// Verify no auto-approval message was sent
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
it('auto-approves browser actions when alwaysAllowBrowser is enabled', async () => {
render(
<ExtensionStateContextProvider>
@@ -153,6 +339,7 @@ describe('ChatView - Auto Approval Tests', () => {
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: true,
clineMessages: [
{
@@ -166,6 +353,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send the browser action ask message
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: true,
clineMessages: [
{
@@ -207,6 +395,7 @@ describe('ChatView - Auto Approval Tests', () => {
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: true,
clineMessages: [
{
@@ -220,6 +409,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send the read-only tool ask message
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: true,
clineMessages: [
{
@@ -262,6 +452,7 @@ describe('ChatView - Auto Approval Tests', () => {
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowWrite: true,
writeDelayMs: 0,
clineMessages: [
@@ -276,6 +467,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send the write tool ask message
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowWrite: true,
writeDelayMs: 0,
clineMessages: [
@@ -318,6 +510,7 @@ describe('ChatView - Auto Approval Tests', () => {
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowWrite: true,
clineMessages: [
{
@@ -331,6 +524,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send a non-tool write operation message
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowWrite: true,
clineMessages: [
{
@@ -371,6 +565,7 @@ describe('ChatView - Auto Approval Tests', () => {
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
@@ -385,6 +580,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send the command ask message
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
@@ -427,6 +623,7 @@ describe('ChatView - Auto Approval Tests', () => {
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
@@ -441,6 +638,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send the disallowed command ask message
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
@@ -498,6 +696,7 @@ describe('ChatView - Auto Approval Tests', () => {
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ['npm test', 'npm run build', 'echo', 'Select-String'],
clineMessages: [
@@ -512,6 +711,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send the chained command ask message
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ['npm test', 'npm run build', 'echo', 'Select-String'],
clineMessages: [
@@ -585,6 +785,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send the chained command ask message
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ['npm test', 'Select-String'],
clineMessages: [
@@ -643,6 +844,7 @@ describe('ChatView - Auto Approval Tests', () => {
jest.clearAllMocks()
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ['npm test', 'Select-String'],
clineMessages: [
@@ -656,6 +858,7 @@ describe('ChatView - Auto Approval Tests', () => {
})
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ['npm test', 'Select-String'],
clineMessages: [
@@ -688,6 +891,7 @@ describe('ChatView - Auto Approval Tests', () => {
jest.clearAllMocks()
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ['npm test', 'Select-String'],
clineMessages: [
@@ -701,6 +905,7 @@ describe('ChatView - Auto Approval Tests', () => {
})
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ['npm test', 'Select-String'],
clineMessages: [
@@ -748,6 +953,7 @@ describe('ChatView - Sound Playing Tests', () => {
// First hydrate state with initial task and streaming
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: true,
clineMessages: [
{
@@ -768,6 +974,7 @@ describe('ChatView - Sound Playing Tests', () => {
// Then send the browser action ask message (streaming finished)
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: true,
clineMessages: [
{
@@ -807,6 +1014,7 @@ describe('ChatView - Sound Playing Tests', () => {
// First hydrate state with initial task and streaming
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: false,
clineMessages: [
{
@@ -827,6 +1035,7 @@ describe('ChatView - Sound Playing Tests', () => {
// Then send the browser action ask message (streaming finished)
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: false,
clineMessages: [
{

View File

@@ -63,6 +63,8 @@ export interface ExtensionStateContextType extends ExtensionState {
setCustomPrompts: (value: CustomPrompts) => void
enhancementApiConfigId?: string
setEnhancementApiConfigId: (value: string) => void
autoApprovalEnabled?: boolean
setAutoApprovalEnabled: (value: boolean) => void
}
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -121,11 +123,22 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const message: ExtensionMessage = event.data
switch (message.type) {
case "state": {
const newState = message.state!
// Set autoApprovalEnabled to true if undefined and any individual flag is true
if (newState.autoApprovalEnabled === undefined) {
newState.autoApprovalEnabled = !!(
newState.alwaysAllowBrowser ||
newState.alwaysAllowReadOnly ||
newState.alwaysAllowWrite ||
newState.alwaysAllowExecute ||
newState.alwaysAllowMcp ||
newState.alwaysApproveResubmit)
}
setState(prevState => ({
...prevState,
...message.state!
...newState
}))
const config = message.state?.apiConfiguration
const config = newState.apiConfiguration
const hasKey = checkExistKey(config)
setShowWelcome(!hasKey)
setDidHydrateState(true)
@@ -237,6 +250,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })),
setCustomPrompts: (value) => setState((prevState) => ({ ...prevState, customPrompts: value })),
setEnhancementApiConfigId: (value) => setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })),
setAutoApprovalEnabled: (value) => setState((prevState) => ({ ...prevState, autoApprovalEnabled: value })),
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>