mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-21 21:01:06 -05:00
Add options to always approve write and execute operations
This commit is contained in:
@@ -65,6 +65,9 @@ export class Cline {
|
||||
private didEditFile: boolean = false
|
||||
customInstructions?: string
|
||||
alwaysAllowReadOnly: boolean
|
||||
alwaysAllowWrite: boolean
|
||||
alwaysAllowExecute: boolean
|
||||
|
||||
apiConversationHistory: Anthropic.MessageParam[] = []
|
||||
clineMessages: ClineMessage[] = []
|
||||
private askResponse?: ClineAskResponse
|
||||
@@ -93,6 +96,8 @@ export class Cline {
|
||||
apiConfiguration: ApiConfiguration,
|
||||
customInstructions?: string,
|
||||
alwaysAllowReadOnly?: boolean,
|
||||
alwaysAllowWrite?: boolean,
|
||||
alwaysAllowExecute?: boolean,
|
||||
task?: string,
|
||||
images?: string[],
|
||||
historyItem?: HistoryItem
|
||||
@@ -105,6 +110,8 @@ export class Cline {
|
||||
this.diffViewProvider = new DiffViewProvider(cwd)
|
||||
this.customInstructions = customInstructions
|
||||
this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false
|
||||
this.alwaysAllowWrite = alwaysAllowWrite ?? false
|
||||
this.alwaysAllowExecute = alwaysAllowExecute ?? false
|
||||
|
||||
if (historyItem) {
|
||||
this.taskId = historyItem.id
|
||||
@@ -1052,7 +1059,11 @@ export class Cline {
|
||||
if (block.partial) {
|
||||
// update gui message
|
||||
const partialMessage = JSON.stringify(sharedMessageProps)
|
||||
await this.ask("tool", partialMessage, block.partial).catch(() => {})
|
||||
if (this.alwaysAllowWrite) {
|
||||
await this.say("tool", partialMessage, undefined, block.partial)
|
||||
} else {
|
||||
await this.ask("tool", partialMessage, block.partial).catch(() => {})
|
||||
}
|
||||
// update editor
|
||||
if (!this.diffViewProvider.isEditing) {
|
||||
// open the editor and prepare to stream content in
|
||||
@@ -1082,7 +1093,11 @@ export class Cline {
|
||||
if (!this.diffViewProvider.isEditing) {
|
||||
// show gui message before showing edit animation
|
||||
const partialMessage = JSON.stringify(sharedMessageProps)
|
||||
await this.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, this shows the edit row before the content is streamed into the editor
|
||||
if (this.alwaysAllowWrite) {
|
||||
await this.say("tool", partialMessage, undefined, true)
|
||||
} else {
|
||||
await this.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, this shows the edit row before the content is streamed into the editor
|
||||
}
|
||||
await this.diffViewProvider.open(relPath)
|
||||
}
|
||||
await this.diffViewProvider.update(newContent, true)
|
||||
@@ -1101,7 +1116,7 @@ export class Cline {
|
||||
)
|
||||
: undefined,
|
||||
} satisfies ClineSayTool)
|
||||
const didApprove = await askApproval("tool", completeMessage)
|
||||
const didApprove = this.alwaysAllowWrite || (await askApproval("tool", completeMessage))
|
||||
if (!didApprove) {
|
||||
await this.diffViewProvider.revertChanges()
|
||||
break
|
||||
@@ -1492,9 +1507,13 @@ export class Cline {
|
||||
const command: string | undefined = block.params.command
|
||||
try {
|
||||
if (block.partial) {
|
||||
await this.ask("command", removeClosingTag("command", command), block.partial).catch(
|
||||
() => {}
|
||||
)
|
||||
if (this.alwaysAllowExecute) {
|
||||
await this.say("command", command, undefined, block.partial)
|
||||
} else {
|
||||
await this.ask("command", removeClosingTag("command", command), block.partial).catch(
|
||||
() => {}
|
||||
)
|
||||
}
|
||||
break
|
||||
} else {
|
||||
if (!command) {
|
||||
@@ -1505,7 +1524,7 @@ export class Cline {
|
||||
break
|
||||
}
|
||||
this.consecutiveMistakeCount = 0
|
||||
const didApprove = await askApproval("command", command)
|
||||
const didApprove = this.alwaysAllowExecute || (await askApproval("command", command))
|
||||
if (!didApprove) {
|
||||
break
|
||||
}
|
||||
|
||||
322
src/core/__tests__/Cline.test.ts
Normal file
322
src/core/__tests__/Cline.test.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { Cline } from '../Cline';
|
||||
import { ClineProvider } from '../webview/ClineProvider';
|
||||
import { ApiConfiguration } from '../../shared/api';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
// Mock fileExistsAtPath
|
||||
jest.mock('../../utils/fs', () => ({
|
||||
fileExistsAtPath: jest.fn().mockImplementation((filePath) => {
|
||||
return filePath.includes('ui_messages.json') ||
|
||||
filePath.includes('api_conversation_history.json');
|
||||
})
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
const mockMessages = [{
|
||||
ts: Date.now(),
|
||||
type: 'say',
|
||||
say: 'text',
|
||||
text: 'historical task'
|
||||
}];
|
||||
|
||||
jest.mock('fs/promises', () => ({
|
||||
mkdir: jest.fn().mockResolvedValue(undefined),
|
||||
writeFile: jest.fn().mockResolvedValue(undefined),
|
||||
readFile: jest.fn().mockImplementation((filePath) => {
|
||||
if (filePath.includes('ui_messages.json')) {
|
||||
return Promise.resolve(JSON.stringify(mockMessages));
|
||||
}
|
||||
if (filePath.includes('api_conversation_history.json')) {
|
||||
return Promise.resolve('[]');
|
||||
}
|
||||
return Promise.resolve('[]');
|
||||
}),
|
||||
unlink: jest.fn().mockResolvedValue(undefined),
|
||||
rmdir: jest.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('vscode', () => {
|
||||
const mockDisposable = { dispose: jest.fn() };
|
||||
const mockEventEmitter = {
|
||||
event: jest.fn(),
|
||||
fire: jest.fn()
|
||||
};
|
||||
|
||||
const mockTextDocument = {
|
||||
uri: {
|
||||
fsPath: '/mock/workspace/path/file.ts'
|
||||
}
|
||||
};
|
||||
|
||||
const mockTextEditor = {
|
||||
document: mockTextDocument
|
||||
};
|
||||
|
||||
const mockTab = {
|
||||
input: {
|
||||
uri: {
|
||||
fsPath: '/mock/workspace/path/file.ts'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mockTabGroup = {
|
||||
tabs: [mockTab]
|
||||
};
|
||||
|
||||
return {
|
||||
window: {
|
||||
createTextEditorDecorationType: jest.fn().mockReturnValue({
|
||||
dispose: jest.fn()
|
||||
}),
|
||||
visibleTextEditors: [mockTextEditor],
|
||||
tabGroups: {
|
||||
all: [mockTabGroup]
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
workspaceFolders: [{
|
||||
uri: {
|
||||
fsPath: '/mock/workspace/path'
|
||||
},
|
||||
name: 'mock-workspace',
|
||||
index: 0
|
||||
}],
|
||||
onDidCreateFiles: jest.fn(() => mockDisposable),
|
||||
onDidDeleteFiles: jest.fn(() => mockDisposable),
|
||||
onDidRenameFiles: jest.fn(() => mockDisposable)
|
||||
},
|
||||
env: {
|
||||
uriScheme: 'vscode',
|
||||
language: 'en'
|
||||
},
|
||||
EventEmitter: jest.fn().mockImplementation(() => mockEventEmitter),
|
||||
Disposable: {
|
||||
from: jest.fn()
|
||||
},
|
||||
TabInputText: jest.fn()
|
||||
};
|
||||
});
|
||||
|
||||
// Mock p-wait-for to resolve immediately
|
||||
jest.mock('p-wait-for', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(async () => Promise.resolve())
|
||||
}));
|
||||
|
||||
jest.mock('delay', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(async () => Promise.resolve())
|
||||
}));
|
||||
|
||||
jest.mock('serialize-error', () => ({
|
||||
__esModule: true,
|
||||
serializeError: jest.fn().mockImplementation((error) => ({
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
}))
|
||||
}));
|
||||
|
||||
jest.mock('strip-ansi', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation((str) => str.replace(/\u001B\[\d+m/g, ''))
|
||||
}));
|
||||
|
||||
jest.mock('globby', () => ({
|
||||
__esModule: true,
|
||||
globby: jest.fn().mockImplementation(async () => [])
|
||||
}));
|
||||
|
||||
jest.mock('os-name', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockReturnValue('Mock OS Name')
|
||||
}));
|
||||
|
||||
jest.mock('default-shell', () => ({
|
||||
__esModule: true,
|
||||
default: '/bin/bash' // Mock default shell path
|
||||
}));
|
||||
|
||||
describe('Cline', () => {
|
||||
let mockProvider: jest.Mocked<ClineProvider>;
|
||||
let mockApiConfig: ApiConfiguration;
|
||||
let mockOutputChannel: any;
|
||||
let mockExtensionContext: vscode.ExtensionContext;
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup mock extension context
|
||||
mockExtensionContext = {
|
||||
globalState: {
|
||||
get: jest.fn().mockImplementation((key) => {
|
||||
if (key === 'taskHistory') {
|
||||
return [{
|
||||
id: '123',
|
||||
ts: Date.now(),
|
||||
task: 'historical task',
|
||||
tokensIn: 100,
|
||||
tokensOut: 200,
|
||||
cacheWrites: 0,
|
||||
cacheReads: 0,
|
||||
totalCost: 0.001
|
||||
}];
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
update: jest.fn().mockImplementation((key, value) => Promise.resolve()),
|
||||
keys: jest.fn().mockReturnValue([])
|
||||
},
|
||||
workspaceState: {
|
||||
get: jest.fn().mockImplementation((key) => undefined),
|
||||
update: jest.fn().mockImplementation((key, value) => Promise.resolve()),
|
||||
keys: jest.fn().mockReturnValue([])
|
||||
},
|
||||
secrets: {
|
||||
get: jest.fn().mockImplementation((key) => Promise.resolve(undefined)),
|
||||
store: jest.fn().mockImplementation((key, value) => Promise.resolve()),
|
||||
delete: jest.fn().mockImplementation((key) => Promise.resolve())
|
||||
},
|
||||
extensionUri: {
|
||||
fsPath: '/mock/extension/path'
|
||||
},
|
||||
globalStorageUri: {
|
||||
fsPath: '/mock/storage/path'
|
||||
},
|
||||
extension: {
|
||||
packageJSON: {
|
||||
version: '1.0.0'
|
||||
}
|
||||
}
|
||||
} as unknown as vscode.ExtensionContext;
|
||||
|
||||
// Setup mock output channel
|
||||
mockOutputChannel = {
|
||||
appendLine: jest.fn(),
|
||||
append: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
show: jest.fn(),
|
||||
hide: jest.fn(),
|
||||
dispose: jest.fn()
|
||||
};
|
||||
|
||||
// Setup mock provider with output channel
|
||||
mockProvider = new ClineProvider(mockExtensionContext, mockOutputChannel) as jest.Mocked<ClineProvider>;
|
||||
|
||||
// Setup mock API configuration
|
||||
mockApiConfig = {
|
||||
apiProvider: 'anthropic',
|
||||
apiModelId: 'claude-3-sonnet'
|
||||
};
|
||||
|
||||
// Mock provider methods
|
||||
mockProvider.postMessageToWebview = jest.fn().mockResolvedValue(undefined);
|
||||
mockProvider.postStateToWebview = jest.fn().mockResolvedValue(undefined);
|
||||
mockProvider.getTaskWithId = jest.fn().mockImplementation(async (id) => ({
|
||||
historyItem: {
|
||||
id,
|
||||
ts: Date.now(),
|
||||
task: 'historical task',
|
||||
tokensIn: 100,
|
||||
tokensOut: 200,
|
||||
cacheWrites: 0,
|
||||
cacheReads: 0,
|
||||
totalCost: 0.001
|
||||
},
|
||||
taskDirPath: '/mock/storage/path/tasks/123',
|
||||
apiConversationHistoryFilePath: '/mock/storage/path/tasks/123/api_conversation_history.json',
|
||||
uiMessagesFilePath: '/mock/storage/path/tasks/123/ui_messages.json',
|
||||
apiConversationHistory: []
|
||||
}));
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with default settings', () => {
|
||||
const cline = new Cline(
|
||||
mockProvider,
|
||||
mockApiConfig,
|
||||
undefined, // customInstructions
|
||||
undefined, // alwaysAllowReadOnly
|
||||
undefined, // alwaysAllowWrite
|
||||
undefined, // alwaysAllowExecute
|
||||
'test task'
|
||||
);
|
||||
|
||||
expect(cline.alwaysAllowReadOnly).toBe(false);
|
||||
expect(cline.alwaysAllowWrite).toBe(false);
|
||||
expect(cline.alwaysAllowExecute).toBe(false);
|
||||
});
|
||||
|
||||
it('should respect provided settings', () => {
|
||||
const cline = new Cline(
|
||||
mockProvider,
|
||||
mockApiConfig,
|
||||
'custom instructions',
|
||||
true, // alwaysAllowReadOnly
|
||||
true, // alwaysAllowWrite
|
||||
true, // alwaysAllowExecute
|
||||
'test task'
|
||||
);
|
||||
|
||||
expect(cline.alwaysAllowReadOnly).toBe(true);
|
||||
expect(cline.alwaysAllowWrite).toBe(true);
|
||||
expect(cline.alwaysAllowExecute).toBe(true);
|
||||
expect(cline.customInstructions).toBe('custom instructions');
|
||||
});
|
||||
|
||||
it('should require either task or historyItem', () => {
|
||||
expect(() => {
|
||||
new Cline(
|
||||
mockProvider,
|
||||
mockApiConfig
|
||||
);
|
||||
}).toThrow('Either historyItem or task/images must be provided');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file operations', () => {
|
||||
let cline: Cline;
|
||||
|
||||
beforeEach(() => {
|
||||
cline = new Cline(
|
||||
mockProvider,
|
||||
mockApiConfig,
|
||||
undefined,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
'test task'
|
||||
);
|
||||
});
|
||||
|
||||
it('should bypass approval when alwaysAllowWrite is true', async () => {
|
||||
const writeEnabledCline = new Cline(
|
||||
mockProvider,
|
||||
mockApiConfig,
|
||||
undefined,
|
||||
false,
|
||||
true, // alwaysAllowWrite
|
||||
false,
|
||||
'test task'
|
||||
);
|
||||
|
||||
expect(writeEnabledCline.alwaysAllowWrite).toBe(true);
|
||||
// The write operation would bypass approval in actual implementation
|
||||
});
|
||||
|
||||
it('should require approval when alwaysAllowWrite is false', async () => {
|
||||
const writeDisabledCline = new Cline(
|
||||
mockProvider,
|
||||
mockApiConfig,
|
||||
undefined,
|
||||
false,
|
||||
false, // alwaysAllowWrite
|
||||
false,
|
||||
'test task'
|
||||
);
|
||||
|
||||
expect(writeDisabledCline.alwaysAllowWrite).toBe(false);
|
||||
// The write operation would require approval in actual implementation
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,8 @@ type GlobalStateKey =
|
||||
| "lastShownAnnouncementId"
|
||||
| "customInstructions"
|
||||
| "alwaysAllowReadOnly"
|
||||
| "alwaysAllowWrite"
|
||||
| "alwaysAllowExecute"
|
||||
| "taskHistory"
|
||||
| "openAiBaseUrl"
|
||||
| "openAiModelId"
|
||||
@@ -185,18 +187,20 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
|
||||
async initClineWithTask(task?: string, images?: string[]) {
|
||||
await this.clearTask() // ensures that an exising task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one
|
||||
const { apiConfiguration, customInstructions, alwaysAllowReadOnly } = await this.getState()
|
||||
this.cline = new Cline(this, apiConfiguration, customInstructions, alwaysAllowReadOnly, task, images)
|
||||
const { apiConfiguration, customInstructions, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute } = await this.getState()
|
||||
this.cline = new Cline(this, apiConfiguration, customInstructions, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, task, images)
|
||||
}
|
||||
|
||||
async initClineWithHistoryItem(historyItem: HistoryItem) {
|
||||
await this.clearTask()
|
||||
const { apiConfiguration, customInstructions, alwaysAllowReadOnly } = await this.getState()
|
||||
const { apiConfiguration, customInstructions, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute } = await this.getState()
|
||||
this.cline = new Cline(
|
||||
this,
|
||||
apiConfiguration,
|
||||
customInstructions,
|
||||
alwaysAllowReadOnly,
|
||||
alwaysAllowWrite,
|
||||
alwaysAllowExecute,
|
||||
undefined,
|
||||
undefined,
|
||||
historyItem
|
||||
@@ -401,6 +405,20 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "alwaysAllowWrite":
|
||||
await this.updateGlobalState("alwaysAllowWrite", message.bool ?? undefined)
|
||||
if (this.cline) {
|
||||
this.cline.alwaysAllowWrite = message.bool ?? false
|
||||
}
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "alwaysAllowExecute":
|
||||
await this.updateGlobalState("alwaysAllowExecute", message.bool ?? undefined)
|
||||
if (this.cline) {
|
||||
this.cline.alwaysAllowExecute = message.bool ?? false
|
||||
}
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "askResponse":
|
||||
this.cline?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
|
||||
break
|
||||
@@ -737,13 +755,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
|
||||
async getStateToPostToWebview() {
|
||||
const { apiConfiguration, lastShownAnnouncementId, customInstructions, alwaysAllowReadOnly, taskHistory } =
|
||||
const { apiConfiguration, lastShownAnnouncementId, customInstructions, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, taskHistory } =
|
||||
await this.getState()
|
||||
return {
|
||||
version: this.context.extension?.packageJSON?.version ?? "",
|
||||
apiConfiguration,
|
||||
customInstructions,
|
||||
alwaysAllowReadOnly,
|
||||
alwaysAllowReadOnly: alwaysAllowReadOnly ?? false,
|
||||
alwaysAllowWrite: alwaysAllowWrite ?? false,
|
||||
alwaysAllowExecute: alwaysAllowExecute ?? false,
|
||||
uriScheme: vscode.env.uriScheme,
|
||||
clineMessages: this.cline?.clineMessages || [],
|
||||
taskHistory: (taskHistory || []).filter((item) => item.ts && item.task).sort((a, b) => b.ts - a.ts),
|
||||
@@ -828,6 +848,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
alwaysAllowReadOnly,
|
||||
alwaysAllowWrite,
|
||||
alwaysAllowExecute,
|
||||
taskHistory,
|
||||
] = await Promise.all([
|
||||
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
|
||||
@@ -854,6 +876,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
|
||||
this.getGlobalState("customInstructions") as Promise<string | undefined>,
|
||||
this.getGlobalState("alwaysAllowReadOnly") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("alwaysAllowWrite") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("alwaysAllowExecute") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
|
||||
])
|
||||
|
||||
@@ -898,6 +922,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
alwaysAllowReadOnly: alwaysAllowReadOnly ?? false,
|
||||
alwaysAllowWrite: alwaysAllowWrite ?? false,
|
||||
alwaysAllowExecute: alwaysAllowExecute ?? false,
|
||||
taskHistory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface ExtensionState {
|
||||
apiConfiguration?: ApiConfiguration
|
||||
customInstructions?: string
|
||||
alwaysAllowReadOnly?: boolean
|
||||
alwaysAllowWrite?: boolean
|
||||
alwaysAllowExecute?: boolean
|
||||
uriScheme?: string
|
||||
clineMessages: ClineMessage[]
|
||||
taskHistory: HistoryItem[]
|
||||
@@ -74,6 +76,7 @@ export type ClineSay =
|
||||
| "shell_integration_warning"
|
||||
| "browser_action"
|
||||
| "browser_action_result"
|
||||
| "command"
|
||||
|
||||
export interface ClineSayTool {
|
||||
tool:
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface WebviewMessage {
|
||||
| "apiConfiguration"
|
||||
| "customInstructions"
|
||||
| "alwaysAllowReadOnly"
|
||||
| "alwaysAllowWrite"
|
||||
| "alwaysAllowExecute"
|
||||
| "webviewDidLaunch"
|
||||
| "newTask"
|
||||
| "askResponse"
|
||||
|
||||
Reference in New Issue
Block a user