From 6b8f9f7a457c17f215254eec3be3b166bf0f043a Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sun, 1 Dec 2024 15:34:36 -0500 Subject: [PATCH] Expose a list of allowed auto-execute commands (#31) --- CHANGELOG.md | 4 +- package.json | 26 +- src/core/webview/ClineProvider.ts | 16 + src/extension.ts | 10 + src/shared/ExtensionMessage.ts | 7 +- src/shared/WebviewMessage.ts | 2 + webview-ui/package-lock.json | 243 +++-- webview-ui/package.json | 9 + webview-ui/src/components/chat/ChatView.tsx | 35 +- .../chat/__tests__/ChatView.test.tsx | 832 +++++++++++------- .../src/components/settings/SettingsView.tsx | 148 +++- .../settings/__tests__/SettingsView.test.tsx | 409 +++++---- .../src/context/ExtensionStateContext.tsx | 3 + .../__tests__/ExtensionStateContext.test.tsx | 60 ++ 14 files changed, 1085 insertions(+), 719 deletions(-) create mode 100644 webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f04ff2..f4cbefc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # Change Log -## Roo Cline 2.1.4 - +## Roo Cline 2.1.8 - Roo Cline now publishes to the VS Code Marketplace! - Roo Cline now allows browser actions without approval when `alwaysAllowBrowser` is true - Roo Cline now can run side-by-side with Cline +- Roo Cline now allows configuration of allowed commands without approval ## [2.1.6] diff --git a/package.json b/package.json index d7b1f02..2470f60 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,7 @@ "displayName": "Roo Cline", "description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.", "publisher": "RooVeterinaryInc", - "version": "2.1.7", - "files": [ - "bin/roo-cline-2.1.7.vsix", - "assets/icons/rocket.png" - ], + "version": "2.1.8", "icon": "assets/icons/rocket.png", "galleryBanner": { "color": "#617A91", @@ -116,6 +112,26 @@ "when": "view == roo-cline.SidebarProvider" } ] + }, + "configuration": { + "title": "RooCline", + "properties": { + "roo-cline.allowedCommands": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "npm test", + "npm install", + "tsc", + "git log", + "git diff", + "git show" + ], + "description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled" + } + } } }, "scripts": { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 25254e7..fbfc5a1 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -60,6 +60,7 @@ type GlobalStateKey = | "azureApiVersion" | "openRouterModelId" | "openRouterModelInfo" + | "allowedCommands" export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", @@ -510,6 +511,13 @@ export class ClineProvider implements vscode.WebviewViewProvider { } break + case "allowedCommands": + await this.context.globalState.update('allowedCommands', message.commands); + // Also update workspace settings + await vscode.workspace + .getConfiguration('roo-cline') + .update('allowedCommands', message.commands, vscode.ConfigurationTarget.Global); + break; // Add more switch case statements here as more webview message commands // are created within the webview context (i.e. inside media/main.js) } @@ -820,6 +828,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { taskHistory, } = await this.getState() + const allowedCommands = vscode.workspace + .getConfiguration('roo-cline') + .get('allowedCommands') || [] + return { version: this.context.extension?.packageJSON?.version ?? "", apiConfiguration, @@ -834,6 +846,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { .filter((item) => item.ts && item.task) .sort((a, b) => b.ts - a.ts), shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId, + allowedCommands, } } @@ -921,6 +934,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { alwaysAllowExecute, alwaysAllowBrowser, taskHistory, + allowedCommands, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -953,6 +967,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("alwaysAllowExecute") as Promise, this.getGlobalState("alwaysAllowBrowser") as Promise, this.getGlobalState("taskHistory") as Promise, + this.getGlobalState("allowedCommands") as Promise, ]) let apiProvider: ApiProvider @@ -1003,6 +1018,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { alwaysAllowExecute: alwaysAllowExecute ?? false, alwaysAllowBrowser: alwaysAllowBrowser ?? false, taskHistory, + allowedCommands, } } diff --git a/src/extension.ts b/src/extension.ts index bd3e780..659c169 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -26,6 +26,16 @@ export function activate(context: vscode.ExtensionContext) { outputChannel.appendLine("Cline extension activated") + // Get default commands from configuration + const defaultCommands = vscode.workspace + .getConfiguration('roo-cline') + .get('allowedCommands') || []; + + // Initialize global state if not already set + if (!context.globalState.get('allowedCommands')) { + context.globalState.update('allowedCommands', defaultCommands); + } + const sidebarProvider = new ClineProvider(context, outputChannel) context.subscriptions.push( diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index beae01a..a4f687b 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -30,6 +30,9 @@ export interface ExtensionMessage { export interface ExtensionState { version: string + clineMessages: ClineMessage[] + taskHistory: HistoryItem[] + shouldShowAnnouncement: boolean apiConfiguration?: ApiConfiguration customInstructions?: string alwaysAllowReadOnly?: boolean @@ -37,9 +40,7 @@ export interface ExtensionState { alwaysAllowExecute?: boolean alwaysAllowBrowser?: boolean uriScheme?: string - clineMessages: ClineMessage[] - taskHistory: HistoryItem[] - shouldShowAnnouncement: boolean + allowedCommands?: string[] } export interface ClineMessage { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index ba23355..c77af6a 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -4,6 +4,7 @@ export interface WebviewMessage { type: | "apiConfiguration" | "customInstructions" + | "allowedCommands" | "alwaysAllowReadOnly" | "alwaysAllowWrite" | "alwaysAllowExecute" @@ -31,6 +32,7 @@ export interface WebviewMessage { apiConfiguration?: ApiConfiguration images?: string[] bool?: boolean + commands?: string[] } export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index ee8d460..0a750e0 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -33,6 +33,7 @@ "web-vitals": "^2.1.4" }, "devDependencies": { + "@babel/plugin-transform-private-property-in-object": "^7.25.9", "@types/vscode-webview": "^1.57.5" } }, @@ -68,12 +69,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", - "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", - "license": "MIT", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "dependencies": { - "@babel/highlight": "^7.24.7", + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", "picocolors": "^1.0.0" }, "engines": { @@ -165,27 +166,26 @@ } }, "node_modules/@babel/generator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.7.tgz", - "integrity": "sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==", - "license": "MIT", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz", + "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==", "dependencies": { - "@babel/types": "^7.24.7", + "@babel/parser": "^7.26.2", + "@babel/types": "^7.26.0", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz", - "integrity": "sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", "dependencies": { - "@babel/types": "^7.24.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -230,19 +230,16 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.7.tgz", - "integrity": "sha512-kTkaDl7c9vO80zeX1rJxnuRpEsD5tA81yh11X1gQo+PhSti3JS+7qeZo9U4RHobKRiFPKaGK3svUAeb8D0Q7eg==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", + "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-member-expression-to-functions": "^7.24.7", - "@babel/helper-optimise-call-expression": "^7.24.7", - "@babel/helper-replace-supers": "^7.24.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.25.9", "semver": "^6.3.1" }, "engines": { @@ -341,13 +338,12 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.7.tgz", - "integrity": "sha512-LGeMaf5JN4hAT471eJdBs/GK1DoYIJ5GCtZN/EsL6KUiiDZOvO/eKE11AMZJa2zP4zk4qe9V2O/hxAmkRc8p6w==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", + "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -386,22 +382,20 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.24.7.tgz", - "integrity": "sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", + "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", "dependencies": { - "@babel/types": "^7.24.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.7.tgz", - "integrity": "sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", + "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", "engines": { "node": ">=6.9.0" } @@ -424,14 +418,13 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.24.7.tgz", - "integrity": "sha512-qTAxxBM81VEyoAY0TtLrx1oAEJc09ZK67Q9ljQToqCnA+55eNwCORaxlKyu+rNfX86o8OXRUSNUnrtsAZXM9sg==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz", + "integrity": "sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==", "dependencies": { - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-member-expression-to-functions": "^7.24.7", - "@babel/helper-optimise-call-expression": "^7.24.7" + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -454,13 +447,12 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.24.7.tgz", - "integrity": "sha512-IO+DLT3LQUElMbpzlatRASEyQtfhSE0+m465v++3jyyXeBTBUjtVZg28/gHeV5mrTJqvEKhKroBGAvhW+qPHiQ==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", + "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -479,19 +471,17 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz", - "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "engines": { "node": ">=6.9.0" } @@ -533,26 +523,13 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/highlight": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", - "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.24.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz", - "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==", - "license": "MIT", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz", + "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==", + "dependencies": { + "@babel/types": "^7.26.0" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -727,18 +704,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-async-generators": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", @@ -1631,15 +1596,13 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.7.tgz", - "integrity": "sha512-9z76mxwnwFxMyxZWEgdgECQglF2Q7cFLm0kMf8pGwt+GSJsY0cONKj/UuO4bOH0w/uAel3ekS4ra5CEAyJRmDA==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", + "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-create-class-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2055,6 +2018,17 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/preset-env/node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://roo-815250993495.d.codeartifact.us-east-1.amazonaws.com/npm/roo-dev/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/preset-env/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -2136,33 +2110,28 @@ } }, "node_modules/@babel/template": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz", - "integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.7.tgz", - "integrity": "sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", + "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-hoist-variables": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7", + "@babel/code-frame": "^7.25.9", + "@babel/generator": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/template": "^7.25.9", + "@babel/types": "^7.25.9", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -2171,14 +2140,12 @@ } }, "node_modules/@babel/types": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz", - "integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==", - "license": "MIT", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", + "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", "dependencies": { - "@babel/helper-string-parser": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -13354,15 +13321,14 @@ } }, "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "license": "MIT", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/json-buffer": { @@ -19238,15 +19204,6 @@ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "license": "BSD-3-Clause" }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/webview-ui/package.json b/webview-ui/package.json index cc5beb6..e9801f0 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -52,6 +52,15 @@ ] }, "devDependencies": { + "@babel/plugin-transform-private-property-in-object": "^7.25.9", "@types/vscode-webview": "^1.57.5" + }, + "jest": { + "transformIgnorePatterns": [ + "/node_modules/(?!(rehype-highlight|react-remark|unist-util-visit|vfile|unified|bail|is-plain-obj|trough|vfile-message|unist-util-stringify-position|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|character-entities|markdown-table|zwitch|longest-streak|escape-string-regexp|unist-util-is|hast-util-to-text)/)" + ], + "moduleNameMapper": { + "\\.(css|less|scss|sass)$": "identity-obj-proxy" + } } } diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index b135fb0..63ea4e2 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -34,18 +34,8 @@ interface ChatViewProps { export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images -const ALLOWED_AUTO_EXECUTE_COMMANDS = [ - 'npm', - 'npx', - 'tsc', - 'git log', - 'git diff', - 'git show', - 'ls' -] as const - const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => { - const { version, clineMessages: messages, taskHistory, apiConfiguration, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute } = useExtensionState() + const { version, clineMessages: messages, taskHistory, apiConfiguration, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, allowedCommands } = 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) @@ -712,19 +702,14 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie if (lastMessage?.type === "ask" && lastMessage.text) { const command = lastMessage.text - // Check for command chaining characters - if (command.includes('&&') || - command.includes(';') || - command.includes('||') || - command.includes('|') || - command.includes('$(') || - command.includes('`')) { - return false - } - const trimmedCommand = command.trim().toLowerCase() - return ALLOWED_AUTO_EXECUTE_COMMANDS.some(prefix => - trimmedCommand.startsWith(prefix.toLowerCase()) - ) + // Split command by chaining operators + const commands = command.split(/&&|\|\||;|\||\$\(|`/).map(cmd => cmd.trim()) + + // Check if all individual commands are allowed + return commands.every((cmd) => { + const trimmedCommand = cmd.toLowerCase() + return allowedCommands?.some((prefix) => trimmedCommand.startsWith(prefix.toLowerCase())) + }) } return false } @@ -737,7 +722,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie ) { handlePrimaryButtonClick() } - }, [clineAsk, enableButtons, handlePrimaryButtonClick, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, messages]) + }, [clineAsk, enableButtons, handlePrimaryButtonClick, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, messages, allowedCommands]) return (
({ - vscode: { - postMessage: jest.fn() - } -})) - -// Mock all components that use problematic dependencies -jest.mock('../../common/CodeBlock', () => ({ - __esModule: true, - default: () =>
-})) - -jest.mock('../../common/MarkdownBlock', () => ({ - __esModule: true, - default: () =>
+ vscode: { + postMessage: jest.fn(), + }, })) +// Mock components that use ESM dependencies jest.mock('../BrowserSessionRow', () => ({ - __esModule: true, - default: () =>
+ __esModule: true, + default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) { + return
{JSON.stringify(messages)}
+ } })) -// Update ChatRow mock to capture props -let chatRowProps = null jest.mock('../ChatRow', () => ({ + __esModule: true, + default: function MockChatRow({ message }: { message: ClineMessage }) { + return
{JSON.stringify(message)}
+ } +})) + +interface ChatTextAreaProps { + onSend: (value: string) => void; + inputValue?: string; + textAreaDisabled?: boolean; + placeholderText?: string; + selectedImages?: string[]; + shouldDisableImages?: boolean; +} + +jest.mock('../ChatTextArea', () => { + const mockReact = require('react') + return { __esModule: true, - default: (props: any) => { - chatRowProps = props - return
- } -})) - -// Mock Virtuoso component -jest.mock('react-virtuoso', () => ({ - Virtuoso: ({ children }: any) => ( -
{children}
- ) -})) - -// Mock VS Code components -jest.mock('@vscode/webview-ui-toolkit/react', () => ({ - VSCodeButton: ({ children, onClick }: any) => ( - - ), - VSCodeProgressRing: () =>
-})) - -describe('ChatView', () => { - const mockShowHistoryView = jest.fn() - const mockHideAnnouncement = jest.fn() - - let mockState: ExtensionStateContextType - - beforeEach(() => { - jest.clearAllMocks() - - mockState = { - clineMessages: [], - apiConfiguration: { - apiProvider: 'anthropic', - apiModelId: 'claude-3-sonnet' - }, - version: '1.0.0', - customInstructions: '', - alwaysAllowReadOnly: true, - alwaysAllowWrite: true, - alwaysAllowExecute: true, - alwaysAllowBrowser: true, - openRouterModels: {}, - didHydrateState: true, - showWelcome: false, - theme: 'dark', - filePaths: [], - taskHistory: [], - shouldShowAnnouncement: false, - uriScheme: 'vscode', - - setApiConfiguration: jest.fn(), - setShowAnnouncement: jest.fn(), - setCustomInstructions: jest.fn(), - setAlwaysAllowReadOnly: jest.fn(), - setAlwaysAllowWrite: jest.fn(), - setAlwaysAllowExecute: jest.fn(), - setAlwaysAllowBrowser: jest.fn() - } - - // Mock the useExtensionState hook - jest.spyOn(ExtensionStateContext, 'useExtensionState').mockReturnValue(mockState) - }) - - const renderChatView = () => { - return render( - - ) - } - - describe('Always Allow Logic', () => { - it('should auto-approve read-only tool actions when alwaysAllowReadOnly is true', () => { - mockState.clineMessages = [ - { - type: 'ask', - ask: 'tool', - text: JSON.stringify({ tool: 'readFile' }), - ts: Date.now(), - } - ] - renderChatView() - - expect(vscode.postMessage).toHaveBeenCalledWith({ - type: 'askResponse', - askResponse: 'yesButtonClicked' - }) - }) - - it('should auto-approve all file listing tool types when alwaysAllowReadOnly is true', () => { - const fileListingTools = [ - 'readFile', 'listFiles', 'listFilesTopLevel', - 'listFilesRecursive', 'listCodeDefinitionNames', 'searchFiles' - ] - - fileListingTools.forEach(tool => { - jest.clearAllMocks() - mockState.clineMessages = [ - { - type: 'ask', - ask: 'tool', - text: JSON.stringify({ tool }), - ts: Date.now(), - } - ] - renderChatView() - - expect(vscode.postMessage).toHaveBeenCalledWith({ - type: 'askResponse', - askResponse: 'yesButtonClicked' - }) - }) - }) - - it('should auto-approve write tool actions when alwaysAllowWrite is true', () => { - mockState.clineMessages = [ - { - type: 'ask', - ask: 'tool', - text: JSON.stringify({ tool: 'editedExistingFile' }), - ts: Date.now(), - } - ] - renderChatView() - - expect(vscode.postMessage).toHaveBeenCalledWith({ - type: 'askResponse', - askResponse: 'yesButtonClicked' - }) - }) - - it('should auto-approve allowed execute commands when alwaysAllowExecute is true', () => { - mockState.clineMessages = [ - { - type: 'ask', - ask: 'command', - text: 'npm install', - ts: Date.now(), - } - ] - renderChatView() - - expect(vscode.postMessage).toHaveBeenCalledWith({ - type: 'askResponse', - askResponse: 'yesButtonClicked' - }) - }) - - it('should not auto-approve disallowed execute commands even when alwaysAllowExecute is true', () => { - mockState.clineMessages = [ - { - type: 'ask', - ask: 'command', - text: 'rm -rf /', - ts: Date.now(), - } - ] - renderChatView() - - expect(vscode.postMessage).not.toHaveBeenCalled() - }) - - it('should not auto-approve commands with chaining characters when alwaysAllowExecute is true', () => { - mockState.clineMessages = [ - { - type: 'ask', - ask: 'command', - text: 'npm install && rm -rf /', - ts: Date.now(), - } - ] - renderChatView() - - expect(vscode.postMessage).not.toHaveBeenCalled() - }) - - it('should auto-approve browser actions when alwaysAllowBrowser is true', () => { - mockState.clineMessages = [ - { - type: 'ask', - ask: 'browser_action_launch', - ts: Date.now(), - } - ] - renderChatView() - - expect(vscode.postMessage).toHaveBeenCalledWith({ - type: 'askResponse', - askResponse: 'yesButtonClicked' - }) - }) - - it('should not auto-approve when corresponding alwaysAllow flag is false', () => { - mockState.alwaysAllowReadOnly = false - mockState.clineMessages = [ - { - type: 'ask', - ask: 'tool', - text: JSON.stringify({ tool: 'readFile' }), - ts: Date.now(), - } - ] - renderChatView() - - expect(vscode.postMessage).not.toHaveBeenCalled() - }) - }) - - describe('Streaming State', () => { - it('should show cancel button while streaming and trigger cancel on click', async () => { - mockState.clineMessages = [ - { - type: 'say', - say: 'task', - ts: Date.now(), - }, - { - type: 'say', - say: 'text', - partial: true, - ts: Date.now(), - } - ] - renderChatView() - - const cancelButton = screen.getByText('Cancel') - await userEvent.click(cancelButton) - - expect(vscode.postMessage).toHaveBeenCalledWith({ - type: 'cancelTask' - }) - }) - - it('should show terminate button when task is paused and trigger terminate on click', async () => { - mockState.clineMessages = [ - { - type: 'ask', - ask: 'resume_task', - ts: Date.now(), - } - ] - renderChatView() - - const terminateButton = screen.getByText('Terminate') - await userEvent.click(terminateButton) - - expect(vscode.postMessage).toHaveBeenCalledWith({ - type: 'clearTask' - }) - }) - - it('should show retry button when API error occurs and trigger retry on click', async () => { - mockState.clineMessages = [ - { - type: 'ask', - ask: 'api_req_failed', - ts: Date.now(), - } - ] - renderChatView() - - const retryButton = screen.getByText('Retry') - await userEvent.click(retryButton) - - expect(vscode.postMessage).toHaveBeenCalledWith({ - type: 'askResponse', - askResponse: 'yesButtonClicked' - }) - }) + default: mockReact.forwardRef(function MockChatTextArea(props: ChatTextAreaProps, ref: React.ForwardedRef) { + return ( +
+ props.onSend(e.target.value)} /> +
+ ) }) + } +}) + +jest.mock('../TaskHeader', () => ({ + __esModule: true, + default: function MockTaskHeader({ task }: { task: ClineMessage }) { + return
{JSON.stringify(task)}
+ } +})) + +// Mock VSCode components +jest.mock('@vscode/webview-ui-toolkit/react', () => ({ + VSCodeButton: function MockVSCodeButton({ + children, + onClick, + appearance + }: { + children: React.ReactNode; + onClick?: () => void; + appearance?: string; + }) { + return + }, + VSCodeTextField: function MockVSCodeTextField({ + value, + onInput, + placeholder + }: { + value?: string; + onInput?: (e: { target: { value: string } }) => void; + placeholder?: string; + }) { + return ( + onInput?.({ target: { value: e.target.value } })} + placeholder={placeholder} + /> + ) + }, + VSCodeLink: function MockVSCodeLink({ + children, + href + }: { + children: React.ReactNode; + href?: string; + }) { + return {children} + } +})) + +// Mock window.postMessage to trigger state hydration +const mockPostMessage = (state: Partial) => { + window.postMessage({ + type: 'state', + state: { + version: '1.0.0', + clineMessages: [], + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + ...state + } + }, '*') +} + +describe('ChatView - Auto Approval Tests', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('auto-approves browser actions when alwaysAllowBrowser is enabled', async () => { + render( + + {}} + showHistoryView={() => {}} + /> + + ) + + // First hydrate state with initial task + mockPostMessage({ + alwaysAllowBrowser: true, + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + } + ] + }) + + // Then send the browser action ask message + mockPostMessage({ + alwaysAllowBrowser: 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' + }) + }) + }) + + it('auto-approves read-only tools when alwaysAllowReadOnly is enabled', async () => { + render( + + {}} + showHistoryView={() => {}} + /> + + ) + + // First hydrate state with initial task + mockPostMessage({ + alwaysAllowReadOnly: true, + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + } + ] + }) + + // Then send the read-only tool ask message + mockPostMessage({ + alwaysAllowReadOnly: 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('auto-approves write tools when alwaysAllowWrite is enabled', async () => { + render( + + {}} + showHistoryView={() => {}} + /> + + ) + + // First hydrate state with initial task + mockPostMessage({ + alwaysAllowWrite: true, + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + } + ] + }) + + // Then send the write tool ask message + mockPostMessage({ + alwaysAllowWrite: true, + 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 allowed commands when alwaysAllowExecute is enabled', async () => { + render( + + {}} + showHistoryView={() => {}} + /> + + ) + + // First hydrate state with initial task + mockPostMessage({ + alwaysAllowExecute: true, + allowedCommands: ['npm test'], + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + } + ] + }) + + // Then send the command ask message + mockPostMessage({ + alwaysAllowExecute: true, + allowedCommands: ['npm test'], + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + }, + { + type: 'ask', + ask: 'command', + ts: Date.now(), + text: 'npm test', + partial: false + } + ] + }) + + // Wait for the auto-approval message + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: 'askResponse', + askResponse: 'yesButtonClicked' + }) + }) + }) + + it('does not auto-approve disallowed commands even when alwaysAllowExecute is enabled', () => { + render( + + {}} + showHistoryView={() => {}} + /> + + ) + + // First hydrate state with initial task + mockPostMessage({ + alwaysAllowExecute: true, + allowedCommands: ['npm test'], + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + } + ] + }) + + // Then send the disallowed command ask message + mockPostMessage({ + alwaysAllowExecute: true, + allowedCommands: ['npm test'], + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + }, + { + type: 'ask', + ask: 'command', + ts: Date.now(), + text: 'rm -rf /', + partial: false + } + ] + }) + + // Verify no auto-approval message was sent + expect(vscode.postMessage).not.toHaveBeenCalledWith({ + type: 'askResponse', + askResponse: 'yesButtonClicked' + }) + }) + + describe('Command Chaining Tests', () => { + it('auto-approves chained commands when all parts are allowed', async () => { + render( + + {}} + showHistoryView={() => {}} + /> + + ) + + // Test various allowed command chaining scenarios + const allowedChainedCommands = [ + 'npm test && npm run build', + 'npm test; npm run build', + 'npm test || npm run build', + 'npm test | npm run build' + ] + + for (const command of allowedChainedCommands) { + jest.clearAllMocks() + + // First hydrate state with initial task + mockPostMessage({ + alwaysAllowExecute: true, + allowedCommands: ['npm test', 'npm run build'], + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + } + ] + }) + + // Then send the chained command ask message + mockPostMessage({ + alwaysAllowExecute: true, + allowedCommands: ['npm test', 'npm run build'], + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + }, + { + type: 'ask', + ask: 'command', + ts: Date.now(), + text: command, + partial: false + } + ] + }) + + // Wait for the auto-approval message + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: 'askResponse', + askResponse: 'yesButtonClicked' + }) + }) + } + }) + + it('does not auto-approve chained commands when any part is disallowed', () => { + render( + + {}} + showHistoryView={() => {}} + /> + + ) + + // Test various command chaining scenarios with disallowed parts + const disallowedChainedCommands = [ + 'npm test && rm -rf /', + 'npm test; rm -rf /', + 'npm test || rm -rf /', + 'npm test | rm -rf /', + 'npm test $(echo dangerous)', + 'npm test `echo dangerous`' + ] + + disallowedChainedCommands.forEach(command => { + // First hydrate state with initial task + mockPostMessage({ + alwaysAllowExecute: true, + allowedCommands: ['npm test'], + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + } + ] + }) + + // Then send the chained command ask message + mockPostMessage({ + alwaysAllowExecute: true, + allowedCommands: ['npm test'], + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + }, + { + type: 'ask', + ask: 'command', + ts: Date.now(), + text: command, + partial: false + } + ] + }) + + // Verify no auto-approval message was sent for chained commands with disallowed parts + expect(vscode.postMessage).not.toHaveBeenCalledWith({ + type: 'askResponse', + askResponse: 'yesButtonClicked' + }) + }) + }) + }) }) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 525807c..5af3c9b 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -1,4 +1,4 @@ -import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextArea } from "@vscode/webview-ui-toolkit/react" +import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextArea, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { memo, useEffect, useState } from "react" import { useExtensionState } from "../../context/ExtensionStateContext" import { validateApiConfiguration, validateModelId } from "../../utils/validate" @@ -26,9 +26,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { alwaysAllowBrowser, setAlwaysAllowBrowser, openRouterModels, + setAllowedCommands, + allowedCommands, } = useExtensionState() const [apiErrorMessage, setApiErrorMessage] = useState(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined) + const [commandInput, setCommandInput] = useState("") + const handleSubmit = () => { const apiValidationResult = validateApiConfiguration(apiConfiguration) const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels) @@ -42,6 +46,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite }) vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute }) vscode.postMessage({ type: "alwaysAllowBrowser", bool: alwaysAllowBrowser }) + vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] }) onDone() } } @@ -51,22 +56,31 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { setModelIdErrorMessage(undefined) }, [apiConfiguration]) - // validate as soon as the component is mounted - /* - useEffect will use stale values of variables if they are not included in the dependency array. so trying to use useEffect with a dependency array of only one value for example will use any other variables' old values. In most cases you don't want this, and should opt to use react-use hooks. - + // Initial validation on mount useEffect(() => { - // uses someVar and anotherVar - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [someVar]) - - If we only want to run code once on mount we can use react-use's useEffectOnce or useMount - */ + const apiValidationResult = validateApiConfiguration(apiConfiguration) + const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels) + setApiErrorMessage(apiValidationResult) + setModelIdErrorMessage(modelIdValidationResult) + }, [apiConfiguration, openRouterModels]) const handleResetState = () => { vscode.postMessage({ type: "resetState" }) } + const handleAddCommand = () => { + const currentCommands = allowedCommands ?? [] + if (commandInput && !currentCommands.includes(commandInput)) { + const newCommands = [...currentCommands, commandInput] + setAllowedCommands(newCommands) + setCommandInput("") + vscode.postMessage({ + type: "allowedCommands", + commands: newCommands + }) + } + } + return (
{ style={{ fontSize: "12px", marginTop: "5px", - color: "var(--vscode-descriptionForeground)", + padding: "8px", + border: "1px solid var(--vscode-errorBorder)", + borderRadius: "4px", + color: "var(--vscode-errorForeground)", }}> - When enabled, Cline will automatically write to files and create directories - without requiring you to click the Approve button. -

-
- -
- setAlwaysAllowExecute(e.target.checked)}> - Always approve execute operations - -

- When enabled, Cline will automatically CLI commands without requiring - you to click the Approve button. + ⚠️ WARNING: When enabled, Cline will automatically create and edit files without requiring approval. This is potentially very dangerous and could lead to unwanted system modifications or security risks. Enable only if you fully trust the AI and understand the risks.

@@ -183,13 +182,90 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { style={{ fontSize: "12px", marginTop: "5px", - color: "var(--vscode-descriptionForeground)", + padding: "8px", + backgroundColor: "var(--vscode-errorBackground)", + border: "1px solid var(--vscode-errorBorder)", + borderRadius: "4px", + color: "var(--vscode-errorForeground)", }}> - When enabled, Cline will automatically perform browser actions without requiring - you to click the Approve button. + ⚠️ WARNING: When enabled, Cline will automatically perform browser actions without requiring approval. This is potentially very dangerous and could lead to unwanted system modifications or security risks. Enable only if you fully trust the AI and understand the risks.

NOTE: The checkbox only applies when the model supports computer use. +

+
+ setAlwaysAllowExecute(e.target.checked)}> + Always approve allowed execute operations + +

+ ⚠️ WARNING: When enabled, Cline will automatically execute allowed terminal commands without requiring approval. This is potentially very dangerous and could lead to unwanted system modifications or security risks. Enable only if you fully trust the AI and understand the risks. +

+
+ + {alwaysAllowExecute && ( +
+
+ Allowed Auto-Execute Commands +

+ Command prefixes that can be auto-executed when "Always approve execute operations" is enabled. +

+ +
+ setCommandInput(e.target.value)} + placeholder="Enter command prefix (e.g., 'git ')" + style={{ flexGrow: 1 }} + /> + + Add + +
+ +
+ {(allowedCommands ?? []).map((cmd, index) => ( +
+ {cmd} + { + const newCommands = (allowedCommands ?? []).filter((_, i) => i !== index) + setAllowedCommands(newCommands) + vscode.postMessage({ + type: "allowedCommands", + commands: newCommands + }) + }} + > + + +
+ ))} +
+
+
+ )} + {IS_DEV && ( <>
Debug
@@ -218,8 +294,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { }}>

If you have any questions or feedback, feel free to open an issue at{" "} - - https://github.com/cline/cline + + https://github.com/RooVetGit/Roo-Cline

v{version}

diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx index d4b5c7f..776c712 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx @@ -1,230 +1,221 @@ -import { render, screen, act } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { ExtensionStateContextType } from '../../../context/ExtensionStateContext' +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' import SettingsView from '../SettingsView' +import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext' import { vscode } from '../../../utils/vscode' -import * as ExtensionStateContext from '../../../context/ExtensionStateContext' -import { ModelInfo } from '../../../../../src/shared/api' -// Mock dependencies +// Mock vscode API jest.mock('../../../utils/vscode', () => ({ - vscode: { - postMessage: jest.fn() - } + vscode: { + postMessage: jest.fn(), + }, })) -// Mock validation functions -jest.mock('../../../utils/validate', () => ({ - validateApiConfiguration: jest.fn(() => undefined), - validateModelId: jest.fn(() => undefined) -})) - -// Mock ApiOptions component -jest.mock('../ApiOptions', () => ({ - __esModule: true, - default: () =>
-})) - -// Mock VS Code components +// Mock VSCode components jest.mock('@vscode/webview-ui-toolkit/react', () => ({ - VSCodeButton: ({ children, onClick }: any) => ( - - ), - VSCodeCheckbox: ({ children, checked, onChange }: any) => ( - - ), - VSCodeTextArea: ({ children, value, onInput }: any) => ( - - ), - VSCodeLink: ({ children, href }: any) => ( - {children} - ) + VSCodeButton: ({ children, onClick, appearance }: any) => ( + appearance === 'icon' ? + : + + ), + VSCodeCheckbox: ({ children, onChange, checked }: any) => ( + + ), + VSCodeTextField: ({ value, onInput, placeholder }: any) => ( + onInput({ target: { value: e.target.value } })} + placeholder={placeholder} + /> + ), + VSCodeTextArea: () =>