Skip to content

Commit

Permalink
feat: add terminal command execution tool with user approval
Browse files Browse the repository at this point in the history
  • Loading branch information
Aaaaash committed Feb 21, 2025
1 parent a7dbc57 commit 9f94f46
Show file tree
Hide file tree
Showing 4 changed files with 291 additions and 68 deletions.
82 changes: 82 additions & 0 deletions packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { memo, useCallback, useMemo, useState } from 'react';

import { useInjectable } from '@opensumi/ide-core-browser';
import { Button, Icon } from '@opensumi/ide-core-browser/lib/components';

import { IMCPServerToolComponentProps } from '../../../types';
import { RunCommandHandler } from '../handlers/RunCommand';

import styles from './index.module.less';

function getResult(raw: string) {
const result: {
isError?: boolean;
text?: string;
} = {};

try {
const data = JSON.parse(raw);
if (data.isError) {
result.isError = data.isError;
}

if (data.content) {
result.text = data.content;
}

return result;
} catch {
return null;
}
}

export const TerminalToolComponent = memo((props: IMCPServerToolComponentProps) => {
const { args, toolCallId } = props;
const handler = useInjectable<RunCommandHandler>(RunCommandHandler);
const [disabled, toggleDisabled] = useState(false);

const handleClick = useCallback((approval: boolean) => {
handler.handleApproval(toolCallId!, approval);
toggleDisabled(true);
}, []);

const output = useMemo(() => {
if (props.result) {
return getResult(props.result);
}
return null;
}, [props]);

return (
<div className={styles.run_cmd_tool}>
{props.state === 'result' && (
<div>
<div className={styles.command_title}>
<Icon icon='terminal' />
<span>输出</span>
</div>
{output ? <div className={styles.command_content}>{output.text}</div> : ''}
</div>
)}

{props.state === 'complete' && args?.require_user_approval && (
<div>
<div className={styles.command_title}>
<Icon icon='terminal' />
<span>是否允许运行命令?</span>
</div>
<p className={styles.command_content}>{args.command}</p>
<p className={styles.comand_description}>{args.explanation}</p>
<div className={styles.cmmand_footer}>
<Button type='link' size='small' disabled={disabled} onClick={() => handleClick(true)}>
允许
</Button>
<Button type='link' size='small' disabled={disabled} onClick={() => handleClick(false)}>
不允许
</Button>
</div>
</div>
)}
</div>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,98 @@
.warning > span {
color: var(--debugConsole-warningForeground);
}

.container {
border: 1px solid var(--vscode-commandCenter-inactiveBorder);
border-radius: 8px;
margin: 8px 0;
overflow: hidden;
}

.header {
padding: 8px 12px;
background-color: var(--design-block-background);
border-bottom: 1px solid var(--vscode-commandCenter-inactiveBorder);
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
color: var(--design-text-foreground);
font-size: 12px;
}

.fileList {
margin: 0;
padding: 0;
list-style: none;
display: block;
background-color: var(--design-block-background);
}

.fileList.collapsed {
display: none;
}

.fileItem {
padding: 2px 6px;
font-size: 12px;
margin: 0px 6px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: var(--design-text-primary);
&:hover {
background-color: var(--design-block-background);
}
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

.fileIcon {
color: var(--design-text-secondary);
font-size: 12px;
width: 16px;
}

.filePath {
color: var(--design-text-secondary);
font-size: 12px;
margin-left: auto;
flex-basis: 0px;
flex-grow: 1;
}

.run_cmd_tool {
.command_title {
display: flex;
align-items: center;
span {
margin-left: 5px;
}
}

.command_content {
padding: 4px;
font-size: 12px;
color: var(--design-text-foreground);
margin: 0px;
background-color: var(--terminal-background);
margin: 10px 0px;
border-radius: 4px;
}

.comand_description {
font-size: 11px;
color: var(--descriptionForeground);
}

.cmmand_footer {
display: flex;
justify-content: flex-end;
}
}
107 changes: 107 additions & 0 deletions packages/ai-native/src/browser/mcp/tools/handlers/RunCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import z from 'zod';

import { Autowired, Injectable } from '@opensumi/di';
import { AppConfig } from '@opensumi/ide-core-browser';
import { ITerminalController, ITerminalGroupViewService } from '@opensumi/ide-terminal-next';
import { Deferred } from '@opensumi/ide-utils/lib/promises';

import { MCPLogger } from '../../../types';

const color = {
italic: '\x1b[3m',
reset: '\x1b[0m',
};

export const inputSchema = z.object({
command: z.string().describe('The terminal command to execute'),
is_background: z.boolean().describe('Whether the command should be run in the background'),
explanation: z
.string()
.describe('One sentence explanation as to why this command needs to be run and how it contributes to the goal.'),
require_user_approval: z
.boolean()
.describe(
"Whether the user must approve the command before it is executed. Only set this to false if the command is safe and if it matches the user's requirements for commands that should be executed automatically.",
),
});

@Injectable()
export class RunCommandHandler {
@Autowired(ITerminalController)
protected readonly terminalController: ITerminalController;

@Autowired(AppConfig)
protected readonly appConfig: AppConfig;

@Autowired(ITerminalGroupViewService)
protected readonly terminalView: ITerminalGroupViewService;

private approvalDeferredMap = new Map<string, Deferred<boolean>>();

private terminalId = 0;

getShellLaunchConfig(command: string) {
return {
name: `MCP:Terminal_${this.terminalId++}`,
cwd: this.appConfig.workspaceDir,
args: ['-c', command],
};
}

async handler(args: z.infer<typeof inputSchema> & { toolCallId: string }, logger: MCPLogger) {
if (args.require_user_approval) {
const def = new Deferred<boolean>();
this.approvalDeferredMap.set(args.toolCallId, def);
const approval = await def.promise;
if (!approval) {
return {
isError: false,
content: 'User rejection',
};
}
}
const terminalClient = await this.terminalController.createTerminalWithWidget({
config: this.getShellLaunchConfig(args.command),
closeWhenExited: false,
});

this.terminalController.showTerminalPanel();

const result: string[] = [];
const def = new Deferred<{ isError?: boolean; content: string[] }>();

terminalClient.onOutput((e) => {
result.push(e.data.toString());
});

terminalClient.onExit((e) => {
const isError = e.code !== 0;
def.resolve({
isError,
content: result,
});

terminalClient.term.writeln(
`\n${color.italic}> Command ${args.command} executed successfully. Terminal will close in ${
3000 / 1000
} seconds.${color.reset}\n`,
);

setTimeout(() => {
terminalClient.dispose();
this.terminalView.removeWidget(terminalClient.id);
}, 3000);
});

return def.promise;
}

handleApproval(callId: string, approval: boolean) {
if (!this.approvalDeferredMap.has(callId)) {
return;
}

const def = this.approvalDeferredMap.get(callId);
def?.resolve(approval);
}
}
75 changes: 7 additions & 68 deletions packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,8 @@ import { ITerminalController, ITerminalGroupViewService } from '@opensumi/ide-te

import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types';

const color = {
italic: '\x1b[3m',
reset: '\x1b[0m',
};

const inputSchema = z.object({
command: z.string().describe('The terminal command to execute'),
is_background: z.boolean().describe('Whether the command should be run in the background'),
explanation: z
.string()
.describe('One sentence explanation as to why this command needs to be run and how it contributes to the goal.'),
require_user_approval: z
.boolean()
.describe(
"Whether the user must approve the command before it is executed. Only set this to false if the command is safe and if it matches the user's requirements for commands that should be executed automatically.",
),
});
import { TerminalToolComponent } from './components/Terminal';
import { RunCommandHandler, inputSchema } from './handlers/RunCommand';

@Domain(MCPServerContribution)
export class RunTerminalCommandTool implements MCPServerContribution {
Expand All @@ -36,10 +21,12 @@ export class RunTerminalCommandTool implements MCPServerContribution {
@Autowired(ITerminalGroupViewService)
protected readonly terminalView: ITerminalGroupViewService;

private terminalId = 0;
@Autowired(RunCommandHandler)
private readonly runCommandHandler: RunCommandHandler;

registerMCPServer(registry: IMCPServerRegistry): void {
registry.registerMCPTool(this.getToolDefinition());
registry.registerToolComponent('run_terminal_cmd', TerminalToolComponent);
}

getToolDefinition(): MCPToolDefinition {
Expand All @@ -52,55 +39,7 @@ export class RunTerminalCommandTool implements MCPServerContribution {
};
}

getShellLaunchConfig(command: string) {
return {
name: `MCP:Terminal_${this.terminalId++}`,
cwd: this.appConfig.workspaceDir,
args: ['-c', command],
};
}

private async handler(args: z.infer<typeof inputSchema>, logger: MCPLogger) {
if (args.require_user_approval) {
// FIXME: support approval
}

const terminalClient = await this.terminalController.createTerminalWithWidget({
config: this.getShellLaunchConfig(args.command),
closeWhenExited: false,
});

this.terminalController.showTerminalPanel();

const result: { type: string; text: string }[] = [];
const def = new Deferred<{ isError?: boolean; content: { type: string; text: string }[] }>();

terminalClient.onOutput((e) => {
result.push({
type: 'output',
text: e.data.toString(),
});
});

terminalClient.onExit((e) => {
const isError = e.code !== 0;
def.resolve({
isError,
content: result,
});

terminalClient.term.writeln(
`\n${color.italic}> Command ${args.command} executed successfully. Terminal will close in ${
3000 / 1000
} seconds.${color.reset}\n`,
);

setTimeout(() => {
terminalClient.dispose();
this.terminalView.removeWidget(terminalClient.id);
}, 3000);
});

return def.promise;
private async handler(args: z.infer<typeof inputSchema> & { toolCallId: string }, logger: MCPLogger) {
return this.runCommandHandler.handler(args, logger);
}
}

0 comments on commit 9f94f46

Please sign in to comment.