Add options to always approve write and execute operations

This commit is contained in:
John Stearns
2024-11-01 13:38:47 -07:00
parent 4658e5cead
commit 3c8a9c09dd
11 changed files with 3555 additions and 390 deletions

View File

@@ -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
}

View 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
});
});
});

View File

@@ -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,
}
}